@open-mercato/scheduler 0.6.5-develop.4458.1.3fcfd7d565 → 0.6.5-develop.4476.1.644044a657

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.
@@ -1,2 +1,2 @@
1
- [build:scheduler] found 42 entry points
1
+ [build:scheduler] found 43 entry points
2
2
  [build:scheduler] built successfully
@@ -0,0 +1,120 @@
1
+ import { expect, test } from "@playwright/test";
2
+ import { apiRequest, getAuthToken } from "@open-mercato/core/helpers/integration/api";
3
+ async function settleAuditClock() {
4
+ await new Promise((resolve) => setTimeout(resolve, 50));
5
+ }
6
+ function readUndoToken(res) {
7
+ const header = res.headers()["x-om-operation"] ?? "";
8
+ const enc = header.startsWith("omop:") ? header.slice(5) : "";
9
+ expect(enc, "x-om-operation header should carry an omop: payload").not.toBe("");
10
+ const payload = JSON.parse(decodeURIComponent(enc));
11
+ expect(typeof payload.undoToken, "undoToken present in operation payload").toBe("string");
12
+ return payload.undoToken;
13
+ }
14
+ async function getById(request, token, id) {
15
+ const res = await apiRequest(request, "GET", `/api/scheduler/jobs?id=${encodeURIComponent(id)}`, { token });
16
+ expect(res.status()).toBe(200);
17
+ const body = await res.json();
18
+ return (body.items ?? [])[0];
19
+ }
20
+ async function createJob(request, token, name) {
21
+ const res = await apiRequest(request, "POST", "/api/scheduler/jobs", {
22
+ token,
23
+ data: {
24
+ name,
25
+ description: "TC-UNDO-001 probe",
26
+ scopeType: "tenant",
27
+ scheduleType: "cron",
28
+ scheduleValue: "0 0 * * *",
29
+ timezone: "UTC",
30
+ targetType: "queue",
31
+ targetQueue: "default",
32
+ isEnabled: true,
33
+ sourceType: "user"
34
+ }
35
+ });
36
+ expect(res.status(), "create returns 201").toBe(201);
37
+ const id = (await res.json()).id;
38
+ expect(id, "create returns an id").toBeTruthy();
39
+ return { id, res };
40
+ }
41
+ async function undo(request, token, undoToken) {
42
+ const res = await apiRequest(request, "POST", "/api/audit_logs/audit-logs/actions/undo", {
43
+ token,
44
+ data: { undoToken }
45
+ });
46
+ const raw = await res.text();
47
+ expect(res.status(), `undo returns 200 (body: ${raw})`).toBe(200);
48
+ const body = JSON.parse(raw);
49
+ expect(body.ok, "undo body is { ok: true }").toBe(true);
50
+ }
51
+ async function cleanup(request, token, id) {
52
+ if (!token || !id) return;
53
+ try {
54
+ await apiRequest(request, "DELETE", "/api/scheduler/jobs", { token, data: { id } });
55
+ } catch {
56
+ }
57
+ }
58
+ test.describe("TC-UNDO-001: scheduler.jobs undo actually restores state (#2504)", () => {
59
+ test("UPDATE -> undo restores the prior name", async ({ request }) => {
60
+ let token = null;
61
+ let id = null;
62
+ try {
63
+ token = await getAuthToken(request, "admin");
64
+ const originalName = `TC-UNDO-001 Update ${Date.now()}`;
65
+ ({ id } = await createJob(request, token, originalName));
66
+ await settleAuditClock();
67
+ const updateRes = await apiRequest(request, "PUT", "/api/scheduler/jobs", {
68
+ token,
69
+ data: { id, name: `${originalName} RENAMED` }
70
+ });
71
+ expect(updateRes.status(), "update returns 200").toBe(200);
72
+ expect((await getById(request, token, id))?.name).toBe(`${originalName} RENAMED`);
73
+ await undo(request, token, readUndoToken(updateRes));
74
+ const restored = await getById(request, token, id);
75
+ expect(restored, "job still exists after update-undo").toBeTruthy();
76
+ expect(restored.name, "name restored to original by undo").toBe(originalName);
77
+ } finally {
78
+ await cleanup(request, token, id);
79
+ }
80
+ });
81
+ test("DELETE -> undo re-lists the soft-deleted job", async ({ request }) => {
82
+ let token = null;
83
+ let id = null;
84
+ try {
85
+ token = await getAuthToken(request, "admin");
86
+ const name = `TC-UNDO-001 Delete ${Date.now()}`;
87
+ ({ id } = await createJob(request, token, name));
88
+ await settleAuditClock();
89
+ const deleteRes = await apiRequest(request, "DELETE", "/api/scheduler/jobs", {
90
+ token,
91
+ data: { id }
92
+ });
93
+ expect(deleteRes.status(), "delete returns 200").toBe(200);
94
+ expect(await getById(request, token, id), "job is gone after delete").toBeFalsy();
95
+ await undo(request, token, readUndoToken(deleteRes));
96
+ const restored = await getById(request, token, id);
97
+ expect(restored, "job re-materialized by delete-undo").toBeTruthy();
98
+ expect(restored.name).toBe(name);
99
+ } finally {
100
+ await cleanup(request, token, id);
101
+ }
102
+ });
103
+ test("CREATE -> undo removes the created job", async ({ request }) => {
104
+ let token = null;
105
+ let id = null;
106
+ try {
107
+ token = await getAuthToken(request, "admin");
108
+ const name = `TC-UNDO-001 Create ${Date.now()}`;
109
+ const created = await createJob(request, token, name);
110
+ id = created.id;
111
+ expect(await getById(request, token, id), "job exists after create").toBeTruthy();
112
+ await undo(request, token, readUndoToken(created.res));
113
+ expect(await getById(request, token, id), "job removed by create-undo").toBeFalsy();
114
+ id = null;
115
+ } finally {
116
+ await cleanup(request, token, id);
117
+ }
118
+ });
119
+ });
120
+ //# sourceMappingURL=TC-UNDO-001-scheduler-jobs.spec.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts"],
4
+ "sourcesContent": ["import { expect, test, type APIRequestContext, type APIResponse } from '@playwright/test'\nimport { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'\n\n/**\n * TC-UNDO-001: scheduler.jobs undo round-trip (regression for issue #2504).\n *\n * Before the fix, every scheduler.jobs undo handler read `logEntry.payload`\n * (always undefined \u2014 the command bus persists the undo snapshot under\n * `commandPayload`), so undo returned `{ ok: true }` while silently changing\n * nothing. This spec exercises the real HTTP path end-to-end:\n * - CREATE -> undo => job is soft-deleted (no longer listed)\n * - UPDATE -> undo => renamed field is restored to its prior value\n * - DELETE -> undo => soft-deleted job is re-materialized\n *\n * Endpoints covered:\n * - POST /api/scheduler/jobs (create)\n * - GET /api/scheduler/jobs?id= (read)\n * - PUT /api/scheduler/jobs (update)\n * - DELETE /api/scheduler/jobs (delete, cleanup)\n * - POST /api/audit_logs/audit-logs/actions/undo (undo)\n */\n\ntype ScheduleRow = {\n id: string\n name: string\n description: string | null\n scheduleValue: string\n}\n\n// The undo endpoint only honors the *latest* undoable log for a resource, and\n// `latestUndoableForResource` orders by `created_at` alone. Audit `created_at` is\n// millisecond-precision (`new Date()` at log time), so a create + a follow-up\n// mutation issued within the same millisecond tie and the lookup may resolve to\n// the wrong log. A short settle guarantees the operation under undo is strictly\n// the most recent, keeping the round-trip deterministic.\nasync function settleAuditClock(): Promise<void> {\n await new Promise((resolve) => setTimeout(resolve, 50))\n}\n\nfunction readUndoToken(res: APIResponse): string {\n const header = res.headers()['x-om-operation'] ?? ''\n const enc = header.startsWith('omop:') ? header.slice(5) : ''\n expect(enc, 'x-om-operation header should carry an omop: payload').not.toBe('')\n const payload = JSON.parse(decodeURIComponent(enc)) as { undoToken?: string }\n expect(typeof payload.undoToken, 'undoToken present in operation payload').toBe('string')\n return payload.undoToken as string\n}\n\nasync function getById(\n request: APIRequestContext,\n token: string,\n id: string,\n): Promise<ScheduleRow | undefined> {\n const res = await apiRequest(request, 'GET', `/api/scheduler/jobs?id=${encodeURIComponent(id)}`, { token })\n expect(res.status()).toBe(200)\n const body = (await res.json()) as { items?: ScheduleRow[] }\n return (body.items ?? [])[0]\n}\n\nasync function createJob(request: APIRequestContext, token: string, name: string): Promise<{ id: string; res: APIResponse }> {\n const res = await apiRequest(request, 'POST', '/api/scheduler/jobs', {\n token,\n data: {\n name,\n description: 'TC-UNDO-001 probe',\n scopeType: 'tenant',\n scheduleType: 'cron',\n scheduleValue: '0 0 * * *',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'default',\n isEnabled: true,\n sourceType: 'user',\n },\n })\n expect(res.status(), 'create returns 201').toBe(201)\n const id = ((await res.json()) as { id: string }).id\n expect(id, 'create returns an id').toBeTruthy()\n return { id, res }\n}\n\nasync function undo(request: APIRequestContext, token: string, undoToken: string): Promise<void> {\n const res = await apiRequest(request, 'POST', '/api/audit_logs/audit-logs/actions/undo', {\n token,\n data: { undoToken },\n })\n const raw = await res.text()\n expect(res.status(), `undo returns 200 (body: ${raw})`).toBe(200)\n const body = JSON.parse(raw) as { ok?: boolean }\n expect(body.ok, 'undo body is { ok: true }').toBe(true)\n}\n\nasync function cleanup(request: APIRequestContext, token: string | null, id: string | null): Promise<void> {\n if (!token || !id) return\n try {\n await apiRequest(request, 'DELETE', '/api/scheduler/jobs', { token, data: { id } })\n } catch {\n /* ignore */\n }\n}\n\ntest.describe('TC-UNDO-001: scheduler.jobs undo actually restores state (#2504)', () => {\n test('UPDATE -> undo restores the prior name', async ({ request }) => {\n let token: string | null = null\n let id: string | null = null\n try {\n token = await getAuthToken(request, 'admin')\n const originalName = `TC-UNDO-001 Update ${Date.now()}`\n ;({ id } = await createJob(request, token, originalName))\n await settleAuditClock()\n\n const updateRes = await apiRequest(request, 'PUT', '/api/scheduler/jobs', {\n token,\n data: { id, name: `${originalName} RENAMED` },\n })\n expect(updateRes.status(), 'update returns 200').toBe(200)\n expect((await getById(request, token, id))?.name).toBe(`${originalName} RENAMED`)\n\n await undo(request, token, readUndoToken(updateRes))\n\n const restored = await getById(request, token, id)\n expect(restored, 'job still exists after update-undo').toBeTruthy()\n expect(restored!.name, 'name restored to original by undo').toBe(originalName)\n } finally {\n await cleanup(request, token, id)\n }\n })\n\n test('DELETE -> undo re-lists the soft-deleted job', async ({ request }) => {\n let token: string | null = null\n let id: string | null = null\n try {\n token = await getAuthToken(request, 'admin')\n const name = `TC-UNDO-001 Delete ${Date.now()}`\n ;({ id } = await createJob(request, token, name))\n await settleAuditClock()\n\n const deleteRes = await apiRequest(request, 'DELETE', '/api/scheduler/jobs', {\n token,\n data: { id },\n })\n expect(deleteRes.status(), 'delete returns 200').toBe(200)\n expect(await getById(request, token, id), 'job is gone after delete').toBeFalsy()\n\n await undo(request, token, readUndoToken(deleteRes))\n\n const restored = await getById(request, token, id)\n expect(restored, 'job re-materialized by delete-undo').toBeTruthy()\n expect(restored!.name).toBe(name)\n } finally {\n await cleanup(request, token, id)\n }\n })\n\n test('CREATE -> undo removes the created job', async ({ request }) => {\n let token: string | null = null\n let id: string | null = null\n try {\n token = await getAuthToken(request, 'admin')\n const name = `TC-UNDO-001 Create ${Date.now()}`\n const created = await createJob(request, token, name)\n id = created.id\n\n expect(await getById(request, token, id), 'job exists after create').toBeTruthy()\n\n await undo(request, token, readUndoToken(created.res))\n\n expect(await getById(request, token, id), 'job removed by create-undo').toBeFalsy()\n id = null // already undone; nothing to clean up\n } finally {\n await cleanup(request, token, id)\n }\n })\n})\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -1,4 +1,5 @@
1
1
  import { registerCommand } from "@open-mercato/shared/lib/commands";
2
+ import { extractUndoPayload } from "@open-mercato/shared/lib/commands/undo";
2
3
  import { ensureOrganizationScope } from "@open-mercato/shared/lib/commands/scope";
3
4
  import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
4
5
  import { ScheduledJob } from "../data/entities.js";
@@ -29,6 +30,37 @@ async function loadScheduleSnapshot(em, scheduleId) {
29
30
  lastRunAt: schedule.lastRunAt ?? null
30
31
  };
31
32
  }
33
+ function toDate(value) {
34
+ if (!value) return null;
35
+ return value instanceof Date ? value : new Date(value);
36
+ }
37
+ function materializeScheduleFromSnapshot(em, snapshot) {
38
+ const now = /* @__PURE__ */ new Date();
39
+ return em.create(ScheduledJob, {
40
+ id: snapshot.id,
41
+ name: snapshot.name,
42
+ description: snapshot.description,
43
+ scopeType: snapshot.scopeType,
44
+ organizationId: snapshot.organizationId,
45
+ tenantId: snapshot.tenantId,
46
+ scheduleType: snapshot.scheduleType,
47
+ scheduleValue: snapshot.scheduleValue,
48
+ timezone: snapshot.timezone,
49
+ targetType: snapshot.targetType,
50
+ targetQueue: snapshot.targetQueue,
51
+ targetCommand: snapshot.targetCommand,
52
+ targetPayload: snapshot.targetPayload,
53
+ requireFeature: snapshot.requireFeature,
54
+ isEnabled: snapshot.isEnabled,
55
+ sourceType: snapshot.sourceType,
56
+ sourceModule: snapshot.sourceModule,
57
+ nextRunAt: toDate(snapshot.nextRunAt),
58
+ lastRunAt: toDate(snapshot.lastRunAt),
59
+ deletedAt: null,
60
+ createdAt: now,
61
+ updatedAt: now
62
+ });
63
+ }
32
64
  async function syncBullMQAfterUndo(ctx, schedule) {
33
65
  try {
34
66
  if (!ctx.container?.resolve) return;
@@ -117,8 +149,7 @@ const createScheduleCommand = {
117
149
  };
118
150
  },
119
151
  async undo({ logEntry, ctx }) {
120
- const undoPayload = logEntry.payload;
121
- const after = undoPayload?.undo?.after;
152
+ const after = extractUndoPayload(logEntry)?.after;
122
153
  if (!after) return;
123
154
  const em = ctx.container.resolve("em").fork();
124
155
  const schedule = await em.findOne(ScheduledJob, { id: after.id });
@@ -200,8 +231,7 @@ const updateScheduleCommand = {
200
231
  };
201
232
  },
202
233
  async undo({ logEntry, ctx }) {
203
- const undoPayload = logEntry.payload;
204
- const before = undoPayload?.undo?.before;
234
+ const before = extractUndoPayload(logEntry)?.before;
205
235
  if (!before) return;
206
236
  const em = ctx.container.resolve("em").fork();
207
237
  const schedule = await em.findOne(ScheduledJob, { id: before.id });
@@ -222,8 +252,8 @@ const updateScheduleCommand = {
222
252
  schedule.isEnabled = before.isEnabled;
223
253
  schedule.sourceType = before.sourceType;
224
254
  schedule.sourceModule = before.sourceModule;
225
- schedule.nextRunAt = before.nextRunAt;
226
- schedule.lastRunAt = before.lastRunAt;
255
+ schedule.nextRunAt = toDate(before.nextRunAt);
256
+ schedule.lastRunAt = toDate(before.lastRunAt);
227
257
  schedule.updatedAt = /* @__PURE__ */ new Date();
228
258
  await em.flush();
229
259
  await syncBullMQAfterUndo(ctx, schedule);
@@ -266,17 +296,17 @@ const deleteScheduleCommand = {
266
296
  };
267
297
  },
268
298
  async undo({ logEntry, ctx }) {
269
- const undoPayload = logEntry.payload;
270
- const before = undoPayload?.undo?.before;
299
+ const before = extractUndoPayload(logEntry)?.before;
271
300
  if (!before) return;
272
301
  const em = ctx.container.resolve("em").fork();
273
- const schedule = await em.findOne(ScheduledJob, { id: before.id });
274
- if (schedule) {
302
+ const existing = await em.findOne(ScheduledJob, { id: before.id });
303
+ const schedule = existing ?? materializeScheduleFromSnapshot(em, before);
304
+ if (existing) {
275
305
  schedule.deletedAt = null;
276
306
  schedule.updatedAt = /* @__PURE__ */ new Date();
277
- await em.flush();
278
- await syncBullMQAfterUndo(ctx, schedule);
279
307
  }
308
+ await em.flush();
309
+ await syncBullMQAfterUndo(ctx, schedule);
280
310
  }
281
311
  };
282
312
  registerCommand(createScheduleCommand);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/scheduler/commands/jobs.ts"],
4
- "sourcesContent": ["import { registerCommand } from '@open-mercato/shared/lib/commands'\nimport type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { ensureOrganizationScope } from '@open-mercato/shared/lib/commands/scope'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { ScheduledJob } from '../data/entities.js'\nimport { calculateNextRun } from '../lib/nextRunCalculator.js'\nimport type {\n ScheduleCreateInput,\n ScheduleUpdateInput,\n} from '../data/validators.js'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { BullMQSchedulerService } from '../services/bullmqSchedulerService.js'\n\n/**\n * Snapshot of a schedule for undo/redo\n */\ntype ScheduleSnapshot = {\n id: string\n name: string\n description: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n organizationId: string | null\n tenantId: string | null\n scheduleType: 'cron' | 'interval'\n scheduleValue: string\n timezone: string\n targetType: 'queue' | 'command'\n targetQueue: string | null\n targetCommand: string | null\n targetPayload: Record<string, unknown> | null\n requireFeature: string | null\n isEnabled: boolean\n sourceType: 'user' | 'module'\n sourceModule: string | null\n nextRunAt: Date | null\n lastRunAt: Date | null\n}\n\n/**\n * Load a schedule snapshot\n */\nasync function loadScheduleSnapshot(\n em: EntityManager,\n scheduleId: string\n): Promise<ScheduleSnapshot | null> {\n const schedule = await em.findOne(ScheduledJob, { id: scheduleId })\n if (!schedule) return null\n\n return {\n id: schedule.id,\n name: schedule.name,\n description: schedule.description ?? null,\n scopeType: schedule.scopeType,\n organizationId: schedule.organizationId ?? null,\n tenantId: schedule.tenantId ?? null,\n scheduleType: schedule.scheduleType,\n scheduleValue: schedule.scheduleValue,\n timezone: schedule.timezone,\n targetType: schedule.targetType,\n targetQueue: schedule.targetQueue ?? null,\n targetCommand: schedule.targetCommand ?? null,\n targetPayload: schedule.targetPayload ?? null,\n requireFeature: schedule.requireFeature ?? null,\n isEnabled: schedule.isEnabled,\n sourceType: schedule.sourceType,\n sourceModule: schedule.sourceModule ?? null,\n nextRunAt: schedule.nextRunAt ?? null,\n lastRunAt: schedule.lastRunAt ?? null,\n }\n}\n\n/**\n * Trigger BullMQ sync after undo operations.\n * Best-effort: if BullMQ service is unavailable (e.g. local strategy), this is a no-op.\n */\nasync function syncBullMQAfterUndo(ctx: CommandRuntimeContext, schedule: ScheduledJob | null): Promise<void> {\n try {\n if (!ctx.container?.resolve) return\n const bullmqService = ctx.container.resolve<BullMQSchedulerService>('bullmqSchedulerService')\n if (!bullmqService) return\n\n if (schedule && schedule.isEnabled && !schedule.deletedAt) {\n await bullmqService.register(schedule, { skipNextRunUpdate: true })\n } else if (schedule) {\n await bullmqService.unregister(schedule.id)\n }\n } catch {\n // Best-effort: BullMQ service may not be registered (local strategy)\n }\n}\n\n/**\n * Ensure tenant/org scope for security\n */\nfunction ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string | null | undefined) {\n if (tenantId && ctx.auth?.tenantId && ctx.auth.tenantId !== tenantId) {\n throw new Error('Tenant mismatch')\n }\n}\n\n// Super-admin status is the immutable `isSuperAdmin` flag derived from\n// RoleAcl/UserAcl at session resolution. Never compare role names to a string\n// like 'superadmin' \u2014 role names are tenant-mutable and trivially spoofable.\nfunction isSuperAdminActor(ctx: CommandRuntimeContext): boolean {\n return ctx.auth?.isSuperAdmin === true\n}\n\n// `ensureTenantScope` is a no-op for system-scoped jobs (tenantId === null), so a\n// `scheduler.jobs.manage` holder could otherwise update/delete a system schedule\n// belonging to the whole deployment. System-scoped jobs MUST be super-admin only,\n// mirroring the create route's system-scope gate.\nfunction ensureCanManageSystemScopedJob(\n ctx: CommandRuntimeContext,\n job: { scopeType?: string | null; tenantId?: string | null },\n): void {\n const isSystemScoped = job.scopeType === 'system' || job.tenantId == null\n if (!isSystemScoped) return\n if (isSuperAdminActor(ctx)) return\n throw new CrudHttpError(403, {\n error: 'System-scoped scheduled jobs can only be managed by a super administrator.',\n })\n}\n\n/**\n * CREATE SCHEDULE COMMAND\n */\nconst createScheduleCommand: CommandHandler<ScheduleCreateInput, { id: string }> = {\n id: 'scheduler.jobs.create',\n\n async execute(input, ctx) {\n ensureTenantScope(ctx, input.tenantId)\n if (input.organizationId) ensureOrganizationScope(ctx, input.organizationId)\n ensureCanManageSystemScopedJob(ctx, { scopeType: input.scopeType, tenantId: input.tenantId })\n\n const em = ctx.container.resolve<EntityManager>('em').fork()\n\n // Calculate next run time\n const nextRunAt = calculateNextRun(\n input.scheduleType,\n input.scheduleValue,\n input.timezone || 'UTC'\n )\n\n if (!nextRunAt) {\n throw new Error('Failed to calculate next run time')\n }\n\n // Create schedule\n const schedule = em.create(ScheduledJob, {\n name: input.name,\n description: input.description ?? null,\n scopeType: input.scopeType,\n organizationId: input.organizationId ?? null,\n tenantId: input.tenantId ?? null,\n scheduleType: input.scheduleType,\n scheduleValue: input.scheduleValue,\n timezone: input.timezone ?? 'UTC',\n targetType: input.targetType,\n targetQueue: input.targetQueue ?? null,\n targetCommand: input.targetCommand ?? null,\n targetPayload: input.targetPayload ?? null,\n requireFeature: input.requireFeature ?? null,\n isEnabled: input.isEnabled ?? true,\n sourceType: input.sourceType ?? 'user',\n sourceModule: input.sourceModule ?? null,\n nextRunAt,\n createdByUserId: (ctx.auth?.userId as string | undefined) ?? null,\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n\n em.persist(schedule)\n await em.flush()\n\n return { id: schedule.id }\n },\n\n async captureAfter(_input, result, ctx) {\n const em = ctx.container.resolve<EntityManager>('em')\n return await loadScheduleSnapshot(em, result.id)\n },\n\n async buildLog({ result, ctx, snapshots }) {\n const { translate } = await resolveTranslations()\n const after = snapshots.after as ScheduleSnapshot | undefined\n\n return {\n actionLabel: translate('scheduler.audit.create', 'Create schedule'),\n resourceKind: 'scheduler.job',\n resourceId: result.id,\n tenantId: after?.tenantId || null,\n organizationId: after?.organizationId || null,\n snapshotAfter: after,\n payload: { undo: { after } },\n }\n },\n\n async undo({ logEntry, ctx }) {\n const undoPayload = logEntry.payload as { undo?: { after?: ScheduleSnapshot } }\n const after = undoPayload?.undo?.after\n if (!after) return\n\n const em = ctx.container.resolve<EntityManager>('em').fork()\n const schedule = await em.findOne(ScheduledJob, { id: after.id })\n\n if (schedule) {\n await em.remove(schedule).flush()\n await syncBullMQAfterUndo(ctx, schedule)\n }\n },\n}\n\n/**\n * UPDATE SCHEDULE COMMAND\n */\nconst updateScheduleCommand: CommandHandler<ScheduleUpdateInput, { ok: boolean }> = {\n id: 'scheduler.jobs.update',\n\n async prepare(input, ctx) {\n const em = ctx.container.resolve<EntityManager>('em')\n const before = await loadScheduleSnapshot(em, input.id)\n return { before }\n },\n\n async execute(input, ctx) {\n const em = ctx.container.resolve<EntityManager>('em').fork()\n\n const schedule = await em.findOne(ScheduledJob, { id: input.id, deletedAt: null })\n if (!schedule) {\n throw new Error('Schedule not found')\n }\n\n ensureTenantScope(ctx, schedule.tenantId)\n if (schedule.organizationId) ensureOrganizationScope(ctx, schedule.organizationId)\n ensureCanManageSystemScopedJob(ctx, schedule)\n\n // Update fields\n if (input.name !== undefined) schedule.name = input.name\n if (input.description !== undefined) schedule.description = input.description ?? null\n if (input.scheduleType !== undefined) schedule.scheduleType = input.scheduleType\n if (input.scheduleValue !== undefined) schedule.scheduleValue = input.scheduleValue\n if (input.timezone !== undefined) schedule.timezone = input.timezone\n if (input.targetPayload !== undefined) schedule.targetPayload = input.targetPayload ?? null\n if (input.requireFeature !== undefined) schedule.requireFeature = input.requireFeature || null\n if (input.isEnabled !== undefined) schedule.isEnabled = input.isEnabled\n \n // Handle target type changes - clear stale values when switching between queue and command\n if (input.targetType !== undefined) {\n schedule.targetType = input.targetType\n \n if (input.targetType === 'queue') {\n // Switching to queue: set new queue and clear command\n if (input.targetQueue !== undefined) schedule.targetQueue = input.targetQueue\n schedule.targetCommand = null\n } else if (input.targetType === 'command') {\n // Switching to command: set new command and clear queue\n if (input.targetCommand !== undefined) schedule.targetCommand = input.targetCommand\n schedule.targetQueue = null\n }\n } else {\n // targetType not changing, but allow updating individual target fields\n if (input.targetQueue !== undefined) schedule.targetQueue = input.targetQueue\n if (input.targetCommand !== undefined) schedule.targetCommand = input.targetCommand\n }\n\n // Recalculate next run if schedule changed\n if (input.scheduleType !== undefined || input.scheduleValue !== undefined || input.timezone !== undefined) {\n const nextRunAt = calculateNextRun(\n schedule.scheduleType,\n schedule.scheduleValue,\n schedule.timezone\n )\n if (nextRunAt) {\n schedule.nextRunAt = nextRunAt\n }\n }\n\n schedule.updatedAt = new Date()\n schedule.updatedByUserId = (ctx.auth?.userId as string | undefined) ?? null\n\n await em.flush()\n\n return { ok: true }\n },\n\n async captureAfter(input, _result, ctx) {\n const em = ctx.container.resolve<EntityManager>('em')\n return await loadScheduleSnapshot(em, input.id)\n },\n\n async buildLog({ input, ctx, snapshots }) {\n const { translate } = await resolveTranslations()\n const before = snapshots.before as ScheduleSnapshot | undefined\n const after = snapshots.after as ScheduleSnapshot | undefined\n\n return {\n actionLabel: translate('scheduler.audit.update', 'Update schedule'),\n resourceKind: 'scheduler.job',\n resourceId: input.id,\n tenantId: after?.tenantId || null,\n organizationId: after?.organizationId || null,\n snapshotBefore: before,\n snapshotAfter: after,\n payload: { undo: { before, after } },\n }\n },\n\n async undo({ logEntry, ctx }) {\n const undoPayload = logEntry.payload as { undo?: { before?: ScheduleSnapshot; after?: ScheduleSnapshot } }\n const before = undoPayload?.undo?.before\n if (!before) return\n\n const em = ctx.container.resolve<EntityManager>('em').fork()\n const schedule = await em.findOne(ScheduledJob, { id: before.id })\n\n if (schedule) {\n // Restore all fields\n schedule.name = before.name\n schedule.description = before.description\n schedule.scopeType = before.scopeType\n schedule.organizationId = before.organizationId\n schedule.tenantId = before.tenantId\n schedule.scheduleType = before.scheduleType\n schedule.scheduleValue = before.scheduleValue\n schedule.timezone = before.timezone\n schedule.targetType = before.targetType\n schedule.targetQueue = before.targetQueue\n schedule.targetCommand = before.targetCommand\n schedule.targetPayload = before.targetPayload\n schedule.requireFeature = before.requireFeature\n schedule.isEnabled = before.isEnabled\n schedule.sourceType = before.sourceType\n schedule.sourceModule = before.sourceModule\n schedule.nextRunAt = before.nextRunAt\n schedule.lastRunAt = before.lastRunAt\n schedule.updatedAt = new Date()\n\n await em.flush()\n await syncBullMQAfterUndo(ctx, schedule)\n }\n },\n}\n\n/**\n * DELETE SCHEDULE COMMAND\n */\nconst deleteScheduleCommand: CommandHandler<{ id: string }, { ok: boolean }> = {\n id: 'scheduler.jobs.delete',\n\n async prepare(input, ctx) {\n const em = ctx.container.resolve<EntityManager>('em')\n const before = await loadScheduleSnapshot(em, input.id)\n return { before }\n },\n\n async execute(input, ctx) {\n const em = ctx.container.resolve<EntityManager>('em').fork()\n\n const schedule = await em.findOne(ScheduledJob, { id: input.id, deletedAt: null })\n if (!schedule) {\n throw new Error('Schedule not found')\n }\n\n ensureTenantScope(ctx, schedule.tenantId)\n if (schedule.organizationId) ensureOrganizationScope(ctx, schedule.organizationId)\n ensureCanManageSystemScopedJob(ctx, schedule)\n\n // Soft delete\n schedule.deletedAt = new Date()\n schedule.updatedAt = new Date()\n schedule.updatedByUserId = (ctx.auth?.userId as string | undefined) ?? null\n\n await em.flush()\n\n return { ok: true }\n },\n\n async buildLog({ input, ctx, snapshots }) {\n const { translate } = await resolveTranslations()\n const before = snapshots.before as ScheduleSnapshot | undefined\n\n return {\n actionLabel: translate('scheduler.audit.delete', 'Delete schedule'),\n resourceKind: 'scheduler.job',\n resourceId: input.id,\n tenantId: before?.tenantId || null,\n organizationId: before?.organizationId || null,\n snapshotBefore: before,\n payload: { undo: { before } },\n }\n },\n\n async undo({ logEntry, ctx }) {\n const undoPayload = logEntry.payload as { undo?: { before?: ScheduleSnapshot } }\n const before = undoPayload?.undo?.before\n if (!before) return\n\n const em = ctx.container.resolve<EntityManager>('em').fork()\n const schedule = await em.findOne(ScheduledJob, { id: before.id })\n\n if (schedule) {\n // Restore by clearing deletedAt\n schedule.deletedAt = null\n schedule.updatedAt = new Date()\n await em.flush()\n await syncBullMQAfterUndo(ctx, schedule)\n }\n },\n}\n\n// Register all commands\nregisterCommand(createScheduleCommand)\nregisterCommand(updateScheduleCommand)\nregisterCommand(deleteScheduleCommand)\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { registerCommand } from '@open-mercato/shared/lib/commands'\nimport type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { extractUndoPayload, type UndoPayload } from '@open-mercato/shared/lib/commands/undo'\nimport { ensureOrganizationScope } from '@open-mercato/shared/lib/commands/scope'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { EntityManager } from '@mikro-orm/core'\nimport { ScheduledJob } from '../data/entities.js'\nimport { calculateNextRun } from '../lib/nextRunCalculator.js'\nimport type {\n ScheduleCreateInput,\n ScheduleUpdateInput,\n} from '../data/validators.js'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { BullMQSchedulerService } from '../services/bullmqSchedulerService.js'\n\n/**\n * Snapshot of a schedule for undo/redo\n */\ntype ScheduleSnapshot = {\n id: string\n name: string\n description: string | null\n scopeType: 'system' | 'organization' | 'tenant'\n organizationId: string | null\n tenantId: string | null\n scheduleType: 'cron' | 'interval'\n scheduleValue: string\n timezone: string\n targetType: 'queue' | 'command'\n targetQueue: string | null\n targetCommand: string | null\n targetPayload: Record<string, unknown> | null\n requireFeature: string | null\n isEnabled: boolean\n sourceType: 'user' | 'module'\n sourceModule: string | null\n nextRunAt: Date | null\n lastRunAt: Date | null\n}\n\n/**\n * Load a schedule snapshot\n */\nasync function loadScheduleSnapshot(\n em: EntityManager,\n scheduleId: string\n): Promise<ScheduleSnapshot | null> {\n const schedule = await em.findOne(ScheduledJob, { id: scheduleId })\n if (!schedule) return null\n\n return {\n id: schedule.id,\n name: schedule.name,\n description: schedule.description ?? null,\n scopeType: schedule.scopeType,\n organizationId: schedule.organizationId ?? null,\n tenantId: schedule.tenantId ?? null,\n scheduleType: schedule.scheduleType,\n scheduleValue: schedule.scheduleValue,\n timezone: schedule.timezone,\n targetType: schedule.targetType,\n targetQueue: schedule.targetQueue ?? null,\n targetCommand: schedule.targetCommand ?? null,\n targetPayload: schedule.targetPayload ?? null,\n requireFeature: schedule.requireFeature ?? null,\n isEnabled: schedule.isEnabled,\n sourceType: schedule.sourceType,\n sourceModule: schedule.sourceModule ?? null,\n nextRunAt: schedule.nextRunAt ?? null,\n lastRunAt: schedule.lastRunAt ?? null,\n }\n}\n\n/**\n * Snapshots are persisted as JSON (in the action log's command payload), so Date\n * fields come back as ISO strings on undo. Coerce them to Date before writing\n * them onto the entity, otherwise MikroORM throws while flushing a Date column.\n */\nfunction toDate(value: Date | string | null | undefined): Date | null {\n if (!value) return null\n return value instanceof Date ? value : new Date(value)\n}\n\n/**\n * Re-create a ScheduledJob entity from a snapshot, preserving its original id.\n * Used by delete-undo when the row was hard-removed rather than soft-deleted.\n */\nfunction materializeScheduleFromSnapshot(em: EntityManager, snapshot: ScheduleSnapshot): ScheduledJob {\n const now = new Date()\n return em.create(ScheduledJob, {\n id: snapshot.id,\n name: snapshot.name,\n description: snapshot.description,\n scopeType: snapshot.scopeType,\n organizationId: snapshot.organizationId,\n tenantId: snapshot.tenantId,\n scheduleType: snapshot.scheduleType,\n scheduleValue: snapshot.scheduleValue,\n timezone: snapshot.timezone,\n targetType: snapshot.targetType,\n targetQueue: snapshot.targetQueue,\n targetCommand: snapshot.targetCommand,\n targetPayload: snapshot.targetPayload,\n requireFeature: snapshot.requireFeature,\n isEnabled: snapshot.isEnabled,\n sourceType: snapshot.sourceType,\n sourceModule: snapshot.sourceModule,\n nextRunAt: toDate(snapshot.nextRunAt),\n lastRunAt: toDate(snapshot.lastRunAt),\n deletedAt: null,\n createdAt: now,\n updatedAt: now,\n })\n}\n\n/**\n * Trigger BullMQ sync after undo operations.\n * Best-effort: if BullMQ service is unavailable (e.g. local strategy), this is a no-op.\n */\nasync function syncBullMQAfterUndo(ctx: CommandRuntimeContext, schedule: ScheduledJob | null): Promise<void> {\n try {\n if (!ctx.container?.resolve) return\n const bullmqService = ctx.container.resolve<BullMQSchedulerService>('bullmqSchedulerService')\n if (!bullmqService) return\n\n if (schedule && schedule.isEnabled && !schedule.deletedAt) {\n await bullmqService.register(schedule, { skipNextRunUpdate: true })\n } else if (schedule) {\n await bullmqService.unregister(schedule.id)\n }\n } catch {\n // Best-effort: BullMQ service may not be registered (local strategy)\n }\n}\n\n/**\n * Ensure tenant/org scope for security\n */\nfunction ensureTenantScope(ctx: CommandRuntimeContext, tenantId: string | null | undefined) {\n if (tenantId && ctx.auth?.tenantId && ctx.auth.tenantId !== tenantId) {\n throw new Error('Tenant mismatch')\n }\n}\n\n// Super-admin status is the immutable `isSuperAdmin` flag derived from\n// RoleAcl/UserAcl at session resolution. Never compare role names to a string\n// like 'superadmin' \u2014 role names are tenant-mutable and trivially spoofable.\nfunction isSuperAdminActor(ctx: CommandRuntimeContext): boolean {\n return ctx.auth?.isSuperAdmin === true\n}\n\n// `ensureTenantScope` is a no-op for system-scoped jobs (tenantId === null), so a\n// `scheduler.jobs.manage` holder could otherwise update/delete a system schedule\n// belonging to the whole deployment. System-scoped jobs MUST be super-admin only,\n// mirroring the create route's system-scope gate.\nfunction ensureCanManageSystemScopedJob(\n ctx: CommandRuntimeContext,\n job: { scopeType?: string | null; tenantId?: string | null },\n): void {\n const isSystemScoped = job.scopeType === 'system' || job.tenantId == null\n if (!isSystemScoped) return\n if (isSuperAdminActor(ctx)) return\n throw new CrudHttpError(403, {\n error: 'System-scoped scheduled jobs can only be managed by a super administrator.',\n })\n}\n\n/**\n * CREATE SCHEDULE COMMAND\n */\nconst createScheduleCommand: CommandHandler<ScheduleCreateInput, { id: string }> = {\n id: 'scheduler.jobs.create',\n\n async execute(input, ctx) {\n ensureTenantScope(ctx, input.tenantId)\n if (input.organizationId) ensureOrganizationScope(ctx, input.organizationId)\n ensureCanManageSystemScopedJob(ctx, { scopeType: input.scopeType, tenantId: input.tenantId })\n\n const em = ctx.container.resolve<EntityManager>('em').fork()\n\n // Calculate next run time\n const nextRunAt = calculateNextRun(\n input.scheduleType,\n input.scheduleValue,\n input.timezone || 'UTC'\n )\n\n if (!nextRunAt) {\n throw new Error('Failed to calculate next run time')\n }\n\n // Create schedule\n const schedule = em.create(ScheduledJob, {\n name: input.name,\n description: input.description ?? null,\n scopeType: input.scopeType,\n organizationId: input.organizationId ?? null,\n tenantId: input.tenantId ?? null,\n scheduleType: input.scheduleType,\n scheduleValue: input.scheduleValue,\n timezone: input.timezone ?? 'UTC',\n targetType: input.targetType,\n targetQueue: input.targetQueue ?? null,\n targetCommand: input.targetCommand ?? null,\n targetPayload: input.targetPayload ?? null,\n requireFeature: input.requireFeature ?? null,\n isEnabled: input.isEnabled ?? true,\n sourceType: input.sourceType ?? 'user',\n sourceModule: input.sourceModule ?? null,\n nextRunAt,\n createdByUserId: (ctx.auth?.userId as string | undefined) ?? null,\n createdAt: new Date(),\n updatedAt: new Date(),\n })\n\n em.persist(schedule)\n await em.flush()\n\n return { id: schedule.id }\n },\n\n async captureAfter(_input, result, ctx) {\n const em = ctx.container.resolve<EntityManager>('em')\n return await loadScheduleSnapshot(em, result.id)\n },\n\n async buildLog({ result, ctx, snapshots }) {\n const { translate } = await resolveTranslations()\n const after = snapshots.after as ScheduleSnapshot | undefined\n\n return {\n actionLabel: translate('scheduler.audit.create', 'Create schedule'),\n resourceKind: 'scheduler.job',\n resourceId: result.id,\n tenantId: after?.tenantId || null,\n organizationId: after?.organizationId || null,\n snapshotAfter: after,\n payload: { undo: { after } },\n }\n },\n\n async undo({ logEntry, ctx }) {\n const after = extractUndoPayload<UndoPayload<ScheduleSnapshot>>(logEntry)?.after\n if (!after) return\n\n const em = ctx.container.resolve<EntityManager>('em').fork()\n const schedule = await em.findOne(ScheduledJob, { id: after.id })\n\n if (schedule) {\n await em.remove(schedule).flush()\n await syncBullMQAfterUndo(ctx, schedule)\n }\n },\n}\n\n/**\n * UPDATE SCHEDULE COMMAND\n */\nconst updateScheduleCommand: CommandHandler<ScheduleUpdateInput, { ok: boolean }> = {\n id: 'scheduler.jobs.update',\n\n async prepare(input, ctx) {\n const em = ctx.container.resolve<EntityManager>('em')\n const before = await loadScheduleSnapshot(em, input.id)\n return { before }\n },\n\n async execute(input, ctx) {\n const em = ctx.container.resolve<EntityManager>('em').fork()\n\n const schedule = await em.findOne(ScheduledJob, { id: input.id, deletedAt: null })\n if (!schedule) {\n throw new Error('Schedule not found')\n }\n\n ensureTenantScope(ctx, schedule.tenantId)\n if (schedule.organizationId) ensureOrganizationScope(ctx, schedule.organizationId)\n ensureCanManageSystemScopedJob(ctx, schedule)\n\n // Update fields\n if (input.name !== undefined) schedule.name = input.name\n if (input.description !== undefined) schedule.description = input.description ?? null\n if (input.scheduleType !== undefined) schedule.scheduleType = input.scheduleType\n if (input.scheduleValue !== undefined) schedule.scheduleValue = input.scheduleValue\n if (input.timezone !== undefined) schedule.timezone = input.timezone\n if (input.targetPayload !== undefined) schedule.targetPayload = input.targetPayload ?? null\n if (input.requireFeature !== undefined) schedule.requireFeature = input.requireFeature || null\n if (input.isEnabled !== undefined) schedule.isEnabled = input.isEnabled\n \n // Handle target type changes - clear stale values when switching between queue and command\n if (input.targetType !== undefined) {\n schedule.targetType = input.targetType\n \n if (input.targetType === 'queue') {\n // Switching to queue: set new queue and clear command\n if (input.targetQueue !== undefined) schedule.targetQueue = input.targetQueue\n schedule.targetCommand = null\n } else if (input.targetType === 'command') {\n // Switching to command: set new command and clear queue\n if (input.targetCommand !== undefined) schedule.targetCommand = input.targetCommand\n schedule.targetQueue = null\n }\n } else {\n // targetType not changing, but allow updating individual target fields\n if (input.targetQueue !== undefined) schedule.targetQueue = input.targetQueue\n if (input.targetCommand !== undefined) schedule.targetCommand = input.targetCommand\n }\n\n // Recalculate next run if schedule changed\n if (input.scheduleType !== undefined || input.scheduleValue !== undefined || input.timezone !== undefined) {\n const nextRunAt = calculateNextRun(\n schedule.scheduleType,\n schedule.scheduleValue,\n schedule.timezone\n )\n if (nextRunAt) {\n schedule.nextRunAt = nextRunAt\n }\n }\n\n schedule.updatedAt = new Date()\n schedule.updatedByUserId = (ctx.auth?.userId as string | undefined) ?? null\n\n await em.flush()\n\n return { ok: true }\n },\n\n async captureAfter(input, _result, ctx) {\n const em = ctx.container.resolve<EntityManager>('em')\n return await loadScheduleSnapshot(em, input.id)\n },\n\n async buildLog({ input, ctx, snapshots }) {\n const { translate } = await resolveTranslations()\n const before = snapshots.before as ScheduleSnapshot | undefined\n const after = snapshots.after as ScheduleSnapshot | undefined\n\n return {\n actionLabel: translate('scheduler.audit.update', 'Update schedule'),\n resourceKind: 'scheduler.job',\n resourceId: input.id,\n tenantId: after?.tenantId || null,\n organizationId: after?.organizationId || null,\n snapshotBefore: before,\n snapshotAfter: after,\n payload: { undo: { before, after } },\n }\n },\n\n async undo({ logEntry, ctx }) {\n const before = extractUndoPayload<UndoPayload<ScheduleSnapshot>>(logEntry)?.before\n if (!before) return\n\n const em = ctx.container.resolve<EntityManager>('em').fork()\n const schedule = await em.findOne(ScheduledJob, { id: before.id })\n\n if (schedule) {\n // Restore all fields\n schedule.name = before.name\n schedule.description = before.description\n schedule.scopeType = before.scopeType\n schedule.organizationId = before.organizationId\n schedule.tenantId = before.tenantId\n schedule.scheduleType = before.scheduleType\n schedule.scheduleValue = before.scheduleValue\n schedule.timezone = before.timezone\n schedule.targetType = before.targetType\n schedule.targetQueue = before.targetQueue\n schedule.targetCommand = before.targetCommand\n schedule.targetPayload = before.targetPayload\n schedule.requireFeature = before.requireFeature\n schedule.isEnabled = before.isEnabled\n schedule.sourceType = before.sourceType\n schedule.sourceModule = before.sourceModule\n schedule.nextRunAt = toDate(before.nextRunAt)\n schedule.lastRunAt = toDate(before.lastRunAt)\n schedule.updatedAt = new Date()\n\n await em.flush()\n await syncBullMQAfterUndo(ctx, schedule)\n }\n },\n}\n\n/**\n * DELETE SCHEDULE COMMAND\n */\nconst deleteScheduleCommand: CommandHandler<{ id: string }, { ok: boolean }> = {\n id: 'scheduler.jobs.delete',\n\n async prepare(input, ctx) {\n const em = ctx.container.resolve<EntityManager>('em')\n const before = await loadScheduleSnapshot(em, input.id)\n return { before }\n },\n\n async execute(input, ctx) {\n const em = ctx.container.resolve<EntityManager>('em').fork()\n\n const schedule = await em.findOne(ScheduledJob, { id: input.id, deletedAt: null })\n if (!schedule) {\n throw new Error('Schedule not found')\n }\n\n ensureTenantScope(ctx, schedule.tenantId)\n if (schedule.organizationId) ensureOrganizationScope(ctx, schedule.organizationId)\n ensureCanManageSystemScopedJob(ctx, schedule)\n\n // Soft delete\n schedule.deletedAt = new Date()\n schedule.updatedAt = new Date()\n schedule.updatedByUserId = (ctx.auth?.userId as string | undefined) ?? null\n\n await em.flush()\n\n return { ok: true }\n },\n\n async buildLog({ input, ctx, snapshots }) {\n const { translate } = await resolveTranslations()\n const before = snapshots.before as ScheduleSnapshot | undefined\n\n return {\n actionLabel: translate('scheduler.audit.delete', 'Delete schedule'),\n resourceKind: 'scheduler.job',\n resourceId: input.id,\n tenantId: before?.tenantId || null,\n organizationId: before?.organizationId || null,\n snapshotBefore: before,\n payload: { undo: { before } },\n }\n },\n\n async undo({ logEntry, ctx }) {\n const before = extractUndoPayload<UndoPayload<ScheduleSnapshot>>(logEntry)?.before\n if (!before) return\n\n const em = ctx.container.resolve<EntityManager>('em').fork()\n const existing = await em.findOne(ScheduledJob, { id: before.id })\n\n // Soft-deleted rows are restored by clearing `deletedAt`. A hard-removed row\n // (no surviving record) is re-materialized from the snapshot so undo is\n // robust to either deletion strategy \u2014 mirroring the sales reference undo.\n const schedule = existing ?? materializeScheduleFromSnapshot(em, before)\n if (existing) {\n schedule.deletedAt = null\n schedule.updatedAt = new Date()\n }\n await em.flush()\n await syncBullMQAfterUndo(ctx, schedule)\n },\n}\n\n// Register all commands\nregisterCommand(createScheduleCommand)\nregisterCommand(updateScheduleCommand)\nregisterCommand(deleteScheduleCommand)\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/scheduler",
3
- "version": "0.6.5-develop.4458.1.3fcfd7d565",
3
+ "version": "0.6.5-develop.4476.1.644044a657",
4
4
  "description": "Database-managed scheduled jobs with admin UI",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -58,13 +58,13 @@
58
58
  "./*/*/*/*/*/*.json": "./src/*/*/*/*/*/*.json"
59
59
  },
60
60
  "dependencies": {
61
- "@open-mercato/events": "0.6.5-develop.4458.1.3fcfd7d565",
62
- "@open-mercato/queue": "0.6.5-develop.4458.1.3fcfd7d565",
61
+ "@open-mercato/events": "0.6.5-develop.4476.1.644044a657",
62
+ "@open-mercato/queue": "0.6.5-develop.4476.1.644044a657",
63
63
  "cron-parser": "^5.5.0"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "@mikro-orm/core": "^7.0.14",
67
- "@open-mercato/shared": "0.6.5-develop.4458.1.3fcfd7d565",
67
+ "@open-mercato/shared": "0.6.5-develop.4476.1.644044a657",
68
68
  "bullmq": "^5.0.0",
69
69
  "ioredis": "^5.0.0",
70
70
  "zod": ">=3.23.0"
@@ -78,7 +78,7 @@
78
78
  }
79
79
  },
80
80
  "devDependencies": {
81
- "@open-mercato/shared": "0.6.5-develop.4458.1.3fcfd7d565",
81
+ "@open-mercato/shared": "0.6.5-develop.4476.1.644044a657",
82
82
  "@types/jest": "^30.0.0",
83
83
  "@types/node": "^25.9.1",
84
84
  "jest": "^30.4.2",
@@ -0,0 +1,174 @@
1
+ import { expect, test, type APIRequestContext, type APIResponse } from '@playwright/test'
2
+ import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
3
+
4
+ /**
5
+ * TC-UNDO-001: scheduler.jobs undo round-trip (regression for issue #2504).
6
+ *
7
+ * Before the fix, every scheduler.jobs undo handler read `logEntry.payload`
8
+ * (always undefined — the command bus persists the undo snapshot under
9
+ * `commandPayload`), so undo returned `{ ok: true }` while silently changing
10
+ * nothing. This spec exercises the real HTTP path end-to-end:
11
+ * - CREATE -> undo => job is soft-deleted (no longer listed)
12
+ * - UPDATE -> undo => renamed field is restored to its prior value
13
+ * - DELETE -> undo => soft-deleted job is re-materialized
14
+ *
15
+ * Endpoints covered:
16
+ * - POST /api/scheduler/jobs (create)
17
+ * - GET /api/scheduler/jobs?id= (read)
18
+ * - PUT /api/scheduler/jobs (update)
19
+ * - DELETE /api/scheduler/jobs (delete, cleanup)
20
+ * - POST /api/audit_logs/audit-logs/actions/undo (undo)
21
+ */
22
+
23
+ type ScheduleRow = {
24
+ id: string
25
+ name: string
26
+ description: string | null
27
+ scheduleValue: string
28
+ }
29
+
30
+ // The undo endpoint only honors the *latest* undoable log for a resource, and
31
+ // `latestUndoableForResource` orders by `created_at` alone. Audit `created_at` is
32
+ // millisecond-precision (`new Date()` at log time), so a create + a follow-up
33
+ // mutation issued within the same millisecond tie and the lookup may resolve to
34
+ // the wrong log. A short settle guarantees the operation under undo is strictly
35
+ // the most recent, keeping the round-trip deterministic.
36
+ async function settleAuditClock(): Promise<void> {
37
+ await new Promise((resolve) => setTimeout(resolve, 50))
38
+ }
39
+
40
+ function readUndoToken(res: APIResponse): string {
41
+ const header = res.headers()['x-om-operation'] ?? ''
42
+ const enc = header.startsWith('omop:') ? header.slice(5) : ''
43
+ expect(enc, 'x-om-operation header should carry an omop: payload').not.toBe('')
44
+ const payload = JSON.parse(decodeURIComponent(enc)) as { undoToken?: string }
45
+ expect(typeof payload.undoToken, 'undoToken present in operation payload').toBe('string')
46
+ return payload.undoToken as string
47
+ }
48
+
49
+ async function getById(
50
+ request: APIRequestContext,
51
+ token: string,
52
+ id: string,
53
+ ): Promise<ScheduleRow | undefined> {
54
+ const res = await apiRequest(request, 'GET', `/api/scheduler/jobs?id=${encodeURIComponent(id)}`, { token })
55
+ expect(res.status()).toBe(200)
56
+ const body = (await res.json()) as { items?: ScheduleRow[] }
57
+ return (body.items ?? [])[0]
58
+ }
59
+
60
+ async function createJob(request: APIRequestContext, token: string, name: string): Promise<{ id: string; res: APIResponse }> {
61
+ const res = await apiRequest(request, 'POST', '/api/scheduler/jobs', {
62
+ token,
63
+ data: {
64
+ name,
65
+ description: 'TC-UNDO-001 probe',
66
+ scopeType: 'tenant',
67
+ scheduleType: 'cron',
68
+ scheduleValue: '0 0 * * *',
69
+ timezone: 'UTC',
70
+ targetType: 'queue',
71
+ targetQueue: 'default',
72
+ isEnabled: true,
73
+ sourceType: 'user',
74
+ },
75
+ })
76
+ expect(res.status(), 'create returns 201').toBe(201)
77
+ const id = ((await res.json()) as { id: string }).id
78
+ expect(id, 'create returns an id').toBeTruthy()
79
+ return { id, res }
80
+ }
81
+
82
+ async function undo(request: APIRequestContext, token: string, undoToken: string): Promise<void> {
83
+ const res = await apiRequest(request, 'POST', '/api/audit_logs/audit-logs/actions/undo', {
84
+ token,
85
+ data: { undoToken },
86
+ })
87
+ const raw = await res.text()
88
+ expect(res.status(), `undo returns 200 (body: ${raw})`).toBe(200)
89
+ const body = JSON.parse(raw) as { ok?: boolean }
90
+ expect(body.ok, 'undo body is { ok: true }').toBe(true)
91
+ }
92
+
93
+ async function cleanup(request: APIRequestContext, token: string | null, id: string | null): Promise<void> {
94
+ if (!token || !id) return
95
+ try {
96
+ await apiRequest(request, 'DELETE', '/api/scheduler/jobs', { token, data: { id } })
97
+ } catch {
98
+ /* ignore */
99
+ }
100
+ }
101
+
102
+ test.describe('TC-UNDO-001: scheduler.jobs undo actually restores state (#2504)', () => {
103
+ test('UPDATE -> undo restores the prior name', async ({ request }) => {
104
+ let token: string | null = null
105
+ let id: string | null = null
106
+ try {
107
+ token = await getAuthToken(request, 'admin')
108
+ const originalName = `TC-UNDO-001 Update ${Date.now()}`
109
+ ;({ id } = await createJob(request, token, originalName))
110
+ await settleAuditClock()
111
+
112
+ const updateRes = await apiRequest(request, 'PUT', '/api/scheduler/jobs', {
113
+ token,
114
+ data: { id, name: `${originalName} RENAMED` },
115
+ })
116
+ expect(updateRes.status(), 'update returns 200').toBe(200)
117
+ expect((await getById(request, token, id))?.name).toBe(`${originalName} RENAMED`)
118
+
119
+ await undo(request, token, readUndoToken(updateRes))
120
+
121
+ const restored = await getById(request, token, id)
122
+ expect(restored, 'job still exists after update-undo').toBeTruthy()
123
+ expect(restored!.name, 'name restored to original by undo').toBe(originalName)
124
+ } finally {
125
+ await cleanup(request, token, id)
126
+ }
127
+ })
128
+
129
+ test('DELETE -> undo re-lists the soft-deleted job', async ({ request }) => {
130
+ let token: string | null = null
131
+ let id: string | null = null
132
+ try {
133
+ token = await getAuthToken(request, 'admin')
134
+ const name = `TC-UNDO-001 Delete ${Date.now()}`
135
+ ;({ id } = await createJob(request, token, name))
136
+ await settleAuditClock()
137
+
138
+ const deleteRes = await apiRequest(request, 'DELETE', '/api/scheduler/jobs', {
139
+ token,
140
+ data: { id },
141
+ })
142
+ expect(deleteRes.status(), 'delete returns 200').toBe(200)
143
+ expect(await getById(request, token, id), 'job is gone after delete').toBeFalsy()
144
+
145
+ await undo(request, token, readUndoToken(deleteRes))
146
+
147
+ const restored = await getById(request, token, id)
148
+ expect(restored, 'job re-materialized by delete-undo').toBeTruthy()
149
+ expect(restored!.name).toBe(name)
150
+ } finally {
151
+ await cleanup(request, token, id)
152
+ }
153
+ })
154
+
155
+ test('CREATE -> undo removes the created job', async ({ request }) => {
156
+ let token: string | null = null
157
+ let id: string | null = null
158
+ try {
159
+ token = await getAuthToken(request, 'admin')
160
+ const name = `TC-UNDO-001 Create ${Date.now()}`
161
+ const created = await createJob(request, token, name)
162
+ id = created.id
163
+
164
+ expect(await getById(request, token, id), 'job exists after create').toBeTruthy()
165
+
166
+ await undo(request, token, readUndoToken(created.res))
167
+
168
+ expect(await getById(request, token, id), 'job removed by create-undo').toBeFalsy()
169
+ id = null // already undone; nothing to clean up
170
+ } finally {
171
+ await cleanup(request, token, id)
172
+ }
173
+ })
174
+ })
@@ -0,0 +1,181 @@
1
+ export {}
2
+
3
+ // Regression coverage for issue #2504: scheduler.jobs undo handlers were silent
4
+ // no-ops because they read `logEntry.payload` (always undefined) instead of the
5
+ // persisted `commandPayload`. These tests construct the logEntry exactly as the
6
+ // command bus persists it (redo-wrapped `commandPayload`, no top-level `payload`)
7
+ // and assert undo actually restores state.
8
+
9
+ const registerCommand = jest.fn()
10
+
11
+ jest.mock('@open-mercato/shared/lib/commands', () => ({
12
+ registerCommand,
13
+ }))
14
+
15
+ jest.mock('@open-mercato/shared/lib/i18n/server', () => ({
16
+ resolveTranslations: jest.fn().mockResolvedValue({
17
+ translate: (_key: string, fallback?: string) => fallback ?? _key,
18
+ }),
19
+ }))
20
+
21
+ function loadCommands() {
22
+ let create: any
23
+ let update: any
24
+ let del: any
25
+ jest.isolateModules(() => {
26
+ require('../jobs')
27
+ create = registerCommand.mock.calls.find(([cmd]) => cmd.id === 'scheduler.jobs.create')?.[0]
28
+ update = registerCommand.mock.calls.find(([cmd]) => cmd.id === 'scheduler.jobs.update')?.[0]
29
+ del = registerCommand.mock.calls.find(([cmd]) => cmd.id === 'scheduler.jobs.delete')?.[0]
30
+ })
31
+ return { create, update, del }
32
+ }
33
+
34
+ function makeEm(schedule: Record<string, unknown> | null) {
35
+ const removeFlush = jest.fn().mockResolvedValue(undefined)
36
+ const created: Record<string, unknown>[] = []
37
+ const em: any = {
38
+ fork: jest.fn().mockReturnThis(),
39
+ findOne: jest.fn().mockResolvedValue(schedule),
40
+ persist: jest.fn(),
41
+ remove: jest.fn().mockReturnValue({ flush: removeFlush }),
42
+ flush: jest.fn().mockResolvedValue(undefined),
43
+ create: jest.fn((_entity: unknown, data: Record<string, unknown>) => {
44
+ created.push(data)
45
+ return data
46
+ }),
47
+ __created: created,
48
+ __removeFlush: removeFlush,
49
+ }
50
+ return em
51
+ }
52
+
53
+ function makeCtx(em: any) {
54
+ return {
55
+ auth: { isSuperAdmin: true, tenantId: null },
56
+ container: { resolve: jest.fn(() => em) },
57
+ } as any
58
+ }
59
+
60
+ // Mirrors command-bus.persistLog: metadata.payload becomes the redo-wrapped
61
+ // `commandPayload`; there is no top-level `payload` on the stored row.
62
+ function persistedLogEntry(snapshots: { before?: unknown; after?: unknown }) {
63
+ return {
64
+ commandPayload: { __redoInput: { id: 'job-1' }, undo: { ...snapshots } },
65
+ snapshotBefore: snapshots.before,
66
+ snapshotAfter: snapshots.after,
67
+ }
68
+ }
69
+
70
+ function baseSnapshot(overrides: Record<string, unknown> = {}) {
71
+ return {
72
+ id: 'job-1',
73
+ name: 'Nightly report',
74
+ description: 'original description',
75
+ scopeType: 'tenant',
76
+ organizationId: null,
77
+ tenantId: 'tenant-a',
78
+ scheduleType: 'cron',
79
+ scheduleValue: '0 0 * * *',
80
+ timezone: 'UTC',
81
+ targetType: 'queue',
82
+ targetQueue: 'default',
83
+ targetCommand: null,
84
+ targetPayload: null,
85
+ requireFeature: null,
86
+ isEnabled: true,
87
+ sourceType: 'user',
88
+ sourceModule: null,
89
+ nextRunAt: null,
90
+ lastRunAt: null,
91
+ ...overrides,
92
+ }
93
+ }
94
+
95
+ describe('scheduler.jobs undo restores state (issue #2504)', () => {
96
+ beforeEach(() => {
97
+ jest.clearAllMocks()
98
+ jest.resetModules()
99
+ })
100
+
101
+ it('update undo restores the prior field values from commandPayload', async () => {
102
+ const { update } = loadCommands()
103
+ expect(update).toBeDefined()
104
+
105
+ const before = baseSnapshot({ name: 'Nightly report' })
106
+ const after = baseSnapshot({ name: 'Nightly report RENAMED' })
107
+ // The live row currently holds the post-update ("after") state.
108
+ const liveRow: Record<string, unknown> = { ...after }
109
+ const em = makeEm(liveRow)
110
+
111
+ await update.undo({ logEntry: persistedLogEntry({ before, after }), ctx: makeCtx(em) })
112
+
113
+ expect(em.flush).toHaveBeenCalled()
114
+ expect(liveRow.name).toBe('Nightly report')
115
+ })
116
+
117
+ it('update undo coerces JSON-serialized Date fields back to Date instances', async () => {
118
+ const { update } = loadCommands()
119
+ // Snapshots persisted in the action log are JSON, so Date fields arrive as
120
+ // ISO strings. They MUST be coerced to Date before assignment, otherwise
121
+ // MikroORM throws on the typed Date column (the real failure behind #2504).
122
+ const before = baseSnapshot({ nextRunAt: '2026-06-05T00:00:00.000Z', lastRunAt: '2026-06-04T00:00:00.000Z' })
123
+ const liveRow: Record<string, unknown> = { ...baseSnapshot() }
124
+ const em = makeEm(liveRow)
125
+
126
+ await update.undo({ logEntry: persistedLogEntry({ before }), ctx: makeCtx(em) })
127
+
128
+ expect(liveRow.nextRunAt).toBeInstanceOf(Date)
129
+ expect(liveRow.lastRunAt).toBeInstanceOf(Date)
130
+ expect((liveRow.nextRunAt as Date).toISOString()).toBe('2026-06-05T00:00:00.000Z')
131
+ })
132
+
133
+ it('update undo is a no-op when no snapshot is recoverable', async () => {
134
+ const { update } = loadCommands()
135
+ const em = makeEm({ id: 'job-1', name: 'whatever' })
136
+ // No commandPayload and no snapshots -> nothing to restore.
137
+ await update.undo({ logEntry: { commandPayload: null }, ctx: makeCtx(em) })
138
+ expect(em.findOne).not.toHaveBeenCalled()
139
+ expect(em.flush).not.toHaveBeenCalled()
140
+ })
141
+
142
+ it('create undo removes the created row using the after snapshot', async () => {
143
+ const { create } = loadCommands()
144
+ const after = baseSnapshot()
145
+ const liveRow = { ...after }
146
+ const em = makeEm(liveRow)
147
+
148
+ await create.undo({ logEntry: persistedLogEntry({ after }), ctx: makeCtx(em) })
149
+
150
+ expect(em.remove).toHaveBeenCalledWith(liveRow)
151
+ expect(em.__removeFlush).toHaveBeenCalled()
152
+ })
153
+
154
+ it('delete undo restores a soft-deleted row by clearing deletedAt', async () => {
155
+ const { del } = loadCommands()
156
+ const before = baseSnapshot()
157
+ const liveRow: Record<string, unknown> = { ...before, deletedAt: new Date() }
158
+ const em = makeEm(liveRow)
159
+
160
+ await del.undo({ logEntry: persistedLogEntry({ before }), ctx: makeCtx(em) })
161
+
162
+ expect(liveRow.deletedAt).toBeNull()
163
+ expect(em.flush).toHaveBeenCalled()
164
+ expect(em.create).not.toHaveBeenCalled()
165
+ })
166
+
167
+ it('delete undo re-materializes a hard-removed row from the snapshot', async () => {
168
+ const { del } = loadCommands()
169
+ const before = baseSnapshot({ name: 'Hard removed job', nextRunAt: '2026-06-05T00:00:00.000Z' })
170
+ // findOne returns null -> the row no longer exists and must be re-created.
171
+ const em = makeEm(null)
172
+
173
+ await del.undo({ logEntry: persistedLogEntry({ before }), ctx: makeCtx(em) })
174
+
175
+ expect(em.create).toHaveBeenCalled()
176
+ expect(em.__created[0]).toMatchObject({ id: 'job-1', name: 'Hard removed job', deletedAt: null })
177
+ // Re-materialization also coerces JSON date strings to Date instances.
178
+ expect(em.__created[0].nextRunAt).toBeInstanceOf(Date)
179
+ expect(em.flush).toHaveBeenCalled()
180
+ })
181
+ })
@@ -1,5 +1,6 @@
1
1
  import { registerCommand } from '@open-mercato/shared/lib/commands'
2
2
  import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
3
+ import { extractUndoPayload, type UndoPayload } from '@open-mercato/shared/lib/commands/undo'
3
4
  import { ensureOrganizationScope } from '@open-mercato/shared/lib/commands/scope'
4
5
  import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
6
  import type { EntityManager } from '@mikro-orm/core'
@@ -70,6 +71,48 @@ async function loadScheduleSnapshot(
70
71
  }
71
72
  }
72
73
 
74
+ /**
75
+ * Snapshots are persisted as JSON (in the action log's command payload), so Date
76
+ * fields come back as ISO strings on undo. Coerce them to Date before writing
77
+ * them onto the entity, otherwise MikroORM throws while flushing a Date column.
78
+ */
79
+ function toDate(value: Date | string | null | undefined): Date | null {
80
+ if (!value) return null
81
+ return value instanceof Date ? value : new Date(value)
82
+ }
83
+
84
+ /**
85
+ * Re-create a ScheduledJob entity from a snapshot, preserving its original id.
86
+ * Used by delete-undo when the row was hard-removed rather than soft-deleted.
87
+ */
88
+ function materializeScheduleFromSnapshot(em: EntityManager, snapshot: ScheduleSnapshot): ScheduledJob {
89
+ const now = new Date()
90
+ return em.create(ScheduledJob, {
91
+ id: snapshot.id,
92
+ name: snapshot.name,
93
+ description: snapshot.description,
94
+ scopeType: snapshot.scopeType,
95
+ organizationId: snapshot.organizationId,
96
+ tenantId: snapshot.tenantId,
97
+ scheduleType: snapshot.scheduleType,
98
+ scheduleValue: snapshot.scheduleValue,
99
+ timezone: snapshot.timezone,
100
+ targetType: snapshot.targetType,
101
+ targetQueue: snapshot.targetQueue,
102
+ targetCommand: snapshot.targetCommand,
103
+ targetPayload: snapshot.targetPayload,
104
+ requireFeature: snapshot.requireFeature,
105
+ isEnabled: snapshot.isEnabled,
106
+ sourceType: snapshot.sourceType,
107
+ sourceModule: snapshot.sourceModule,
108
+ nextRunAt: toDate(snapshot.nextRunAt),
109
+ lastRunAt: toDate(snapshot.lastRunAt),
110
+ deletedAt: null,
111
+ createdAt: now,
112
+ updatedAt: now,
113
+ })
114
+ }
115
+
73
116
  /**
74
117
  * Trigger BullMQ sync after undo operations.
75
118
  * Best-effort: if BullMQ service is unavailable (e.g. local strategy), this is a no-op.
@@ -197,8 +240,7 @@ const createScheduleCommand: CommandHandler<ScheduleCreateInput, { id: string }>
197
240
  },
198
241
 
199
242
  async undo({ logEntry, ctx }) {
200
- const undoPayload = logEntry.payload as { undo?: { after?: ScheduleSnapshot } }
201
- const after = undoPayload?.undo?.after
243
+ const after = extractUndoPayload<UndoPayload<ScheduleSnapshot>>(logEntry)?.after
202
244
  if (!after) return
203
245
 
204
246
  const em = ctx.container.resolve<EntityManager>('em').fork()
@@ -307,8 +349,7 @@ const updateScheduleCommand: CommandHandler<ScheduleUpdateInput, { ok: boolean }
307
349
  },
308
350
 
309
351
  async undo({ logEntry, ctx }) {
310
- const undoPayload = logEntry.payload as { undo?: { before?: ScheduleSnapshot; after?: ScheduleSnapshot } }
311
- const before = undoPayload?.undo?.before
352
+ const before = extractUndoPayload<UndoPayload<ScheduleSnapshot>>(logEntry)?.before
312
353
  if (!before) return
313
354
 
314
355
  const em = ctx.container.resolve<EntityManager>('em').fork()
@@ -332,8 +373,8 @@ const updateScheduleCommand: CommandHandler<ScheduleUpdateInput, { ok: boolean }
332
373
  schedule.isEnabled = before.isEnabled
333
374
  schedule.sourceType = before.sourceType
334
375
  schedule.sourceModule = before.sourceModule
335
- schedule.nextRunAt = before.nextRunAt
336
- schedule.lastRunAt = before.lastRunAt
376
+ schedule.nextRunAt = toDate(before.nextRunAt)
377
+ schedule.lastRunAt = toDate(before.lastRunAt)
337
378
  schedule.updatedAt = new Date()
338
379
 
339
380
  await em.flush()
@@ -392,20 +433,22 @@ const deleteScheduleCommand: CommandHandler<{ id: string }, { ok: boolean }> = {
392
433
  },
393
434
 
394
435
  async undo({ logEntry, ctx }) {
395
- const undoPayload = logEntry.payload as { undo?: { before?: ScheduleSnapshot } }
396
- const before = undoPayload?.undo?.before
436
+ const before = extractUndoPayload<UndoPayload<ScheduleSnapshot>>(logEntry)?.before
397
437
  if (!before) return
398
438
 
399
439
  const em = ctx.container.resolve<EntityManager>('em').fork()
400
- const schedule = await em.findOne(ScheduledJob, { id: before.id })
440
+ const existing = await em.findOne(ScheduledJob, { id: before.id })
401
441
 
402
- if (schedule) {
403
- // Restore by clearing deletedAt
442
+ // Soft-deleted rows are restored by clearing `deletedAt`. A hard-removed row
443
+ // (no surviving record) is re-materialized from the snapshot so undo is
444
+ // robust to either deletion strategy — mirroring the sales reference undo.
445
+ const schedule = existing ?? materializeScheduleFromSnapshot(em, before)
446
+ if (existing) {
404
447
  schedule.deletedAt = null
405
448
  schedule.updatedAt = new Date()
406
- await em.flush()
407
- await syncBullMQAfterUndo(ctx, schedule)
408
449
  }
450
+ await em.flush()
451
+ await syncBullMQAfterUndo(ctx, schedule)
409
452
  },
410
453
  }
411
454