@open-mercato/scheduler 0.6.5-develop.4463.1.4c4698f8f8 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js +120 -0
- package/dist/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.js.map +7 -0
- package/dist/modules/scheduler/commands/jobs.js +42 -12
- package/dist/modules/scheduler/commands/jobs.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/scheduler/__integration__/TC-UNDO-001-scheduler-jobs.spec.ts +174 -0
- package/src/modules/scheduler/commands/__tests__/jobs.undo.test.ts +181 -0
- package/src/modules/scheduler/commands/jobs.ts +56 -13
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
[build:scheduler] found
|
|
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
|
|
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
|
|
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
|
|
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
|
|
274
|
-
|
|
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.
|
|
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.
|
|
62
|
-
"@open-mercato/queue": "0.6.5-develop.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
440
|
+
const existing = await em.findOne(ScheduledJob, { id: before.id })
|
|
401
441
|
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|