@open-mercato/core 0.6.6-develop.6089.1.a7ed560528 → 0.6.6-develop.6094.1.28b081ea16
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.
|
@@ -13,13 +13,23 @@ import {
|
|
|
13
13
|
} from "../lib/actions.js";
|
|
14
14
|
import { getMessageType } from "../lib/message-types-registry.js";
|
|
15
15
|
import { assertOrganizationAccess } from "./shared.js";
|
|
16
|
+
const actionStateSnapshotSchema = z.object({
|
|
17
|
+
actionTaken: z.string().nullable(),
|
|
18
|
+
actionTakenByUserId: z.string().nullable(),
|
|
19
|
+
actionTakenAt: z.string().nullable(),
|
|
20
|
+
actionResult: z.record(z.string(), z.unknown()).nullable()
|
|
21
|
+
});
|
|
16
22
|
const recordTerminalActionSchema = z.object({
|
|
17
23
|
messageId: z.string().uuid(),
|
|
18
24
|
actionId: z.string().min(1),
|
|
19
25
|
result: z.record(z.string(), z.unknown()),
|
|
20
26
|
tenantId: z.string().uuid(),
|
|
21
27
|
organizationId: z.string().uuid().nullable(),
|
|
22
|
-
userId: z.string().uuid()
|
|
28
|
+
userId: z.string().uuid(),
|
|
29
|
+
// Optional pre-claim snapshot supplied by `messages.actions.execute` so the
|
|
30
|
+
// undo log records the true (un-taken) state even though the terminal action
|
|
31
|
+
// was atomically claimed before this finalizer runs.
|
|
32
|
+
previousState: actionStateSnapshotSchema.optional()
|
|
23
33
|
});
|
|
24
34
|
const executeActionSchema = z.object({
|
|
25
35
|
messageId: z.string().uuid(),
|
|
@@ -100,12 +110,29 @@ const recordTerminalActionCommand = {
|
|
|
100
110
|
const input = recordTerminalActionSchema.parse(rawInput);
|
|
101
111
|
const em = ctx.container.resolve("em").fork();
|
|
102
112
|
const message = await requireActionTarget(em, input);
|
|
103
|
-
|
|
104
|
-
|
|
113
|
+
const claimedByCaller = message.actionTaken === input.actionId && message.actionTakenByUserId === input.userId;
|
|
114
|
+
if (!claimedByCaller) {
|
|
115
|
+
if (message.actionTaken) {
|
|
116
|
+
throw new Error("Action already taken");
|
|
117
|
+
}
|
|
118
|
+
const claimedRows = await em.nativeUpdate(
|
|
119
|
+
Message,
|
|
120
|
+
{ id: input.messageId, tenantId: input.tenantId, actionTaken: null, deletedAt: null },
|
|
121
|
+
{
|
|
122
|
+
actionTaken: input.actionId,
|
|
123
|
+
actionTakenByUserId: input.userId,
|
|
124
|
+
actionTakenAt: /* @__PURE__ */ new Date()
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
if (claimedRows === 0) {
|
|
128
|
+
throw new Error("Action already taken");
|
|
129
|
+
}
|
|
130
|
+
message.actionTaken = input.actionId;
|
|
131
|
+
message.actionTakenByUserId = input.userId;
|
|
132
|
+
}
|
|
133
|
+
if (!message.actionTakenAt) {
|
|
134
|
+
message.actionTakenAt = /* @__PURE__ */ new Date();
|
|
105
135
|
}
|
|
106
|
-
message.actionTaken = input.actionId;
|
|
107
|
-
message.actionTakenByUserId = input.userId;
|
|
108
|
-
message.actionTakenAt = /* @__PURE__ */ new Date();
|
|
109
136
|
message.actionResult = input.result;
|
|
110
137
|
await em.flush();
|
|
111
138
|
return { ok: true };
|
|
@@ -118,6 +145,7 @@ const recordTerminalActionCommand = {
|
|
|
118
145
|
},
|
|
119
146
|
buildLog: async ({ input, snapshots }) => {
|
|
120
147
|
const parsed = recordTerminalActionSchema.parse(input);
|
|
148
|
+
const before = parsed.previousState ?? snapshots.before ?? null;
|
|
121
149
|
return {
|
|
122
150
|
actionLabel: "Execute message terminal action",
|
|
123
151
|
resourceKind: "messages.message",
|
|
@@ -126,11 +154,11 @@ const recordTerminalActionCommand = {
|
|
|
126
154
|
organizationId: parsed.organizationId,
|
|
127
155
|
payload: {
|
|
128
156
|
undo: {
|
|
129
|
-
before
|
|
157
|
+
before,
|
|
130
158
|
after: snapshots.after ?? null
|
|
131
159
|
}
|
|
132
160
|
},
|
|
133
|
-
snapshotBefore:
|
|
161
|
+
snapshotBefore: before,
|
|
134
162
|
snapshotAfter: snapshots.after ?? null
|
|
135
163
|
};
|
|
136
164
|
},
|
|
@@ -179,6 +207,46 @@ const executeActionCommand = {
|
|
|
179
207
|
}
|
|
180
208
|
}
|
|
181
209
|
}
|
|
210
|
+
const previousState = snapshotActionState(message);
|
|
211
|
+
let claimedTerminal = false;
|
|
212
|
+
const releaseTerminalClaim = async () => {
|
|
213
|
+
if (!claimedTerminal) return;
|
|
214
|
+
claimedTerminal = false;
|
|
215
|
+
await em.nativeUpdate(
|
|
216
|
+
Message,
|
|
217
|
+
{
|
|
218
|
+
id: message.id,
|
|
219
|
+
tenantId: input.tenantId,
|
|
220
|
+
actionTaken: action.id,
|
|
221
|
+
actionTakenByUserId: input.userId
|
|
222
|
+
},
|
|
223
|
+
{ actionTaken: null, actionTakenByUserId: null, actionTakenAt: null }
|
|
224
|
+
);
|
|
225
|
+
};
|
|
226
|
+
if (shouldRecordActionTaken) {
|
|
227
|
+
const claimedRows = await em.nativeUpdate(
|
|
228
|
+
Message,
|
|
229
|
+
{ id: message.id, tenantId: input.tenantId, actionTaken: null, deletedAt: null },
|
|
230
|
+
{
|
|
231
|
+
actionTaken: action.id,
|
|
232
|
+
actionTakenByUserId: input.userId,
|
|
233
|
+
actionTakenAt: /* @__PURE__ */ new Date()
|
|
234
|
+
}
|
|
235
|
+
);
|
|
236
|
+
if (claimedRows === 0) {
|
|
237
|
+
const current = await findOneWithDecryption(
|
|
238
|
+
em,
|
|
239
|
+
Message,
|
|
240
|
+
{ id: message.id, tenantId: input.tenantId, deletedAt: null },
|
|
241
|
+
void 0,
|
|
242
|
+
{ tenantId: input.tenantId, organizationId: input.organizationId }
|
|
243
|
+
);
|
|
244
|
+
throw Object.assign(new Error("Action already taken"), {
|
|
245
|
+
actionTaken: current?.actionTaken ?? message.actionTaken ?? action.id
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
claimedTerminal = true;
|
|
249
|
+
}
|
|
182
250
|
const commandBus = ctx.container.resolve("commandBus");
|
|
183
251
|
let result = {};
|
|
184
252
|
let operationLogEntry = null;
|
|
@@ -223,6 +291,7 @@ const executeActionCommand = {
|
|
|
223
291
|
operationLogEntry = commandResult.logEntry ?? null;
|
|
224
292
|
} catch (err) {
|
|
225
293
|
console.error("[messages] executeActionCommand sub-command failed", err);
|
|
294
|
+
await releaseTerminalClaim();
|
|
226
295
|
throw new Error("Action failed");
|
|
227
296
|
}
|
|
228
297
|
} else if (action.href) {
|
|
@@ -232,10 +301,12 @@ const executeActionCommand = {
|
|
|
232
301
|
userId: input.userId
|
|
233
302
|
});
|
|
234
303
|
if (!safeRedirect) {
|
|
304
|
+
await releaseTerminalClaim();
|
|
235
305
|
throw new Error("Action has an unsafe redirect target");
|
|
236
306
|
}
|
|
237
307
|
result = { redirect: safeRedirect };
|
|
238
308
|
} else {
|
|
309
|
+
await releaseTerminalClaim();
|
|
239
310
|
throw new Error("Action has no executable target");
|
|
240
311
|
}
|
|
241
312
|
if (shouldRecordActionTaken) {
|
|
@@ -246,7 +317,8 @@ const executeActionCommand = {
|
|
|
246
317
|
result,
|
|
247
318
|
tenantId: input.tenantId,
|
|
248
319
|
organizationId: input.organizationId,
|
|
249
|
-
userId: input.userId
|
|
320
|
+
userId: input.userId,
|
|
321
|
+
previousState
|
|
250
322
|
},
|
|
251
323
|
ctx: {
|
|
252
324
|
container: ctx.container,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/messages/commands/actions.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { z } from 'zod'\nimport { registerCommand, type CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { extractUndoPayload, type UndoPayload } from '@open-mercato/shared/lib/commands/undo'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { Message, MessageObject, MessageRecipient } from '../data/entities'\nimport { emitMessagesEvent } from '../events'\nimport {\n findResolvedMessageActionById,\n isTerminalMessageAction,\n resolveActionCommandInput,\n resolveActionHref,\n resolveMessageActionData,\n} from '../lib/actions'\nimport { getMessageType } from '../lib/message-types-registry'\nimport { assertOrganizationAccess, type MessageScopeInput } from './shared'\n\nconst recordTerminalActionSchema = z.object({\n messageId: z.string().uuid(),\n actionId: z.string().min(1),\n result: z.record(z.string(), z.unknown()),\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n userId: z.string().uuid(),\n})\n\ntype RecordTerminalActionInput = z.infer<typeof recordTerminalActionSchema>\n\nconst executeActionSchema = z.object({\n messageId: z.string().uuid(),\n actionId: z.string().min(1),\n payload: z.record(z.string(), z.unknown()).optional(),\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n userId: z.string().uuid(),\n})\n\ntype ExecuteActionInput = z.infer<typeof executeActionSchema>\n\ntype ActionStateSnapshot = {\n actionTaken: string | null\n actionTakenByUserId: string | null\n actionTakenAt: string | null\n actionResult: Record<string, unknown> | null\n}\n\nfunction toIso(value: Date | null | undefined): string | null {\n return value ? value.toISOString() : null\n}\n\nfunction toDate(value: string | null | undefined): Date | null {\n if (!value) return null\n return new Date(value)\n}\n\nasync function requireActionTarget(em: EntityManager, input: RecordTerminalActionInput) {\n const message = await findOneWithDecryption(\n em,\n Message,\n {\n id: input.messageId,\n tenantId: input.tenantId,\n deletedAt: null,\n },\n undefined,\n { tenantId: input.tenantId, organizationId: input.organizationId },\n )\n if (!message) throw new Error('Message not found')\n assertOrganizationAccess(input as MessageScopeInput, message)\n\n const recipient = await em.findOne(MessageRecipient, {\n messageId: input.messageId,\n recipientUserId: input.userId,\n deletedAt: null,\n })\n if (!recipient) throw new Error('Access denied')\n return message\n}\n\nasync function requireActionMessage(em: EntityManager, input: ExecuteActionInput) {\n const message = await findOneWithDecryption(\n em,\n Message,\n {\n id: input.messageId,\n tenantId: input.tenantId,\n deletedAt: null,\n },\n undefined,\n { tenantId: input.tenantId, organizationId: input.organizationId },\n )\n if (!message) throw new Error('Message not found')\n assertOrganizationAccess(input as MessageScopeInput, message)\n const recipient = await em.findOne(MessageRecipient, {\n messageId: input.messageId,\n recipientUserId: input.userId,\n deletedAt: null,\n })\n if (!recipient) throw new Error('Access denied')\n return message\n}\n\nfunction snapshotActionState(message: Message): ActionStateSnapshot {\n return {\n actionTaken: message.actionTaken ?? null,\n actionTakenByUserId: message.actionTakenByUserId ?? null,\n actionTakenAt: toIso(message.actionTakenAt),\n actionResult: message.actionResult ?? null,\n }\n}\n\nconst recordTerminalActionCommand: CommandHandler<unknown, { ok: true }> = {\n id: 'messages.actions.record_terminal',\n async prepare(rawInput, ctx) {\n const input = recordTerminalActionSchema.parse(rawInput)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const message = await requireActionTarget(em, input)\n return { before: snapshotActionState(message) }\n },\n async execute(rawInput, ctx) {\n const input = recordTerminalActionSchema.parse(rawInput)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const message = await requireActionTarget(em, input)\n if (message.actionTaken) {\n throw new Error('Action already taken')\n }\n message.actionTaken = input.actionId\n message.actionTakenByUserId = input.userId\n message.actionTakenAt = new Date()\n message.actionResult = input.result\n await em.flush()\n return { ok: true }\n },\n async captureAfter(rawInput, _result, ctx) {\n const input = recordTerminalActionSchema.parse(rawInput)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const message = await requireActionTarget(em, input)\n return snapshotActionState(message)\n },\n buildLog: async ({ input, snapshots }) => {\n const parsed = recordTerminalActionSchema.parse(input)\n return {\n actionLabel: 'Execute message terminal action',\n resourceKind: 'messages.message',\n resourceId: parsed.messageId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n payload: {\n undo: {\n before: (snapshots.before as ActionStateSnapshot | undefined) ?? null,\n after: (snapshots.after as ActionStateSnapshot | undefined) ?? null,\n } satisfies UndoPayload<ActionStateSnapshot>,\n },\n snapshotBefore: snapshots.before ?? null,\n snapshotAfter: snapshots.after ?? null,\n }\n },\n undo: async ({ logEntry, ctx }) => {\n const undo = extractUndoPayload<UndoPayload<ActionStateSnapshot>>(logEntry)\n const before = undo?.before\n if (!before) return\n const messageId = logEntry?.resourceId as string | null\n if (!messageId) return\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const message = await findOneWithDecryption(em, Message, { id: messageId })\n if (!message) return\n message.actionTaken = before.actionTaken\n message.actionTakenByUserId = before.actionTakenByUserId\n message.actionTakenAt = toDate(before.actionTakenAt)\n message.actionResult = before.actionResult\n await em.flush()\n },\n}\n\nconst executeActionCommand: CommandHandler<\n unknown,\n { ok: true; actionId: string; result: Record<string, unknown>; operationLogEntry: unknown | null }\n> = {\n id: 'messages.actions.execute',\n async execute(rawInput, ctx) {\n const input = executeActionSchema.parse(rawInput)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const message = await requireActionMessage(em, input)\n const objects = await em.find(MessageObject, { messageId: message.id })\n const action = findResolvedMessageActionById(message, objects, input.actionId)\n\n if (!action) {\n throw new Error('Action not found')\n }\n\n if (message.actionTaken) {\n throw Object.assign(new Error('Action already taken'), { actionTaken: message.actionTaken })\n }\n\n const shouldRecordActionTaken = isTerminalMessageAction(action)\n\n const actionData = resolveMessageActionData(message)\n if (actionData?.expiresAt) {\n if (new Date(actionData.expiresAt) < new Date()) {\n throw new Error('Actions have expired')\n }\n } else {\n const messageType = getMessageType(message.type)\n if (messageType?.actionsExpireAfterHours && message.sentAt) {\n const expiry = new Date(message.sentAt.getTime() + messageType.actionsExpireAfterHours * 60 * 60 * 1000)\n if (expiry < new Date()) {\n throw new Error('Actions have expired')\n }\n }\n }\n\n const commandBus = ctx.container.resolve('commandBus') as {\n execute: (\n commandId: string,\n options: { input: unknown; ctx: unknown; metadata?: unknown }\n ) => Promise<{ result: unknown; logEntry?: unknown }>\n }\n\n let result: Record<string, unknown> = {}\n let operationLogEntry: unknown | null = null\n\n if (action.commandId) {\n try {\n const actionInput = resolveActionCommandInput(\n action,\n message,\n {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n userId: input.userId,\n },\n input.payload ?? {},\n )\n\n if (actionInput.id == null) {\n const fallbackId = action.objectRef?.entityId ?? message.sourceEntityId ?? null\n if (typeof fallbackId === 'string' && fallbackId.trim().length > 0) {\n actionInput.id = fallbackId\n }\n }\n\n const commandResult = await commandBus.execute(action.commandId, {\n input: actionInput,\n ctx: {\n container: ctx.container,\n auth: {\n sub: input.userId,\n tenantId: input.tenantId,\n orgId: input.organizationId,\n },\n organizationScope: null,\n selectedOrganizationId: input.organizationId,\n organizationIds: input.organizationId ? [input.organizationId] : null,\n },\n metadata: {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n resourceKind: 'messages',\n },\n })\n\n result = (commandResult.result as Record<string, unknown>) ?? {}\n operationLogEntry = commandResult.logEntry ?? null\n } catch (err) {\n console.error('[messages] executeActionCommand sub-command failed', err)\n throw new Error('Action failed')\n }\n } else if (action.href) {\n const safeRedirect = resolveActionHref(action, message, {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n userId: input.userId,\n })\n if (!safeRedirect) {\n throw new Error('Action has an unsafe redirect target')\n }\n result = { redirect: safeRedirect }\n } else {\n throw new Error('Action has no executable target')\n }\n\n if (shouldRecordActionTaken) {\n const terminalResult = await commandBus.execute('messages.actions.record_terminal', {\n input: {\n messageId: message.id,\n actionId: action.id,\n result,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n userId: input.userId,\n },\n ctx: {\n container: ctx.container,\n auth: ctx.auth ?? null,\n organizationScope: null,\n selectedOrganizationId: input.organizationId,\n organizationIds: input.organizationId ? [input.organizationId] : null,\n },\n })\n if (!operationLogEntry) {\n operationLogEntry = terminalResult.logEntry ?? null\n }\n await emitMessagesEvent(\n 'messages.message.action_taken',\n {\n messageId: message.id,\n actionId: action.id,\n userId: input.userId,\n recipientUserId: input.userId,\n result,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n },\n { persistent: true }\n )\n }\n\n return {\n ok: true,\n actionId: action.id,\n result,\n operationLogEntry,\n }\n },\n}\n\nregisterCommand(recordTerminalActionCommand)\nregisterCommand(executeActionCommand)\n"],
|
|
5
|
-
"mappings": "AACA,SAAS,SAAS;AAClB,SAAS,uBAA4C;AACrD,SAAS,0BAA4C;AACrD,SAAS,6BAA6B;AACtC,SAAS,SAAS,eAAe,wBAAwB;AACzD,SAAS,yBAAyB;AAClC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,sBAAsB;AAC/B,SAAS,gCAAwD;AAEjE,MAAM,6BAA6B,EAAE,OAAO;AAAA,EAC1C,WAAW,EAAE,OAAO,EAAE,KAAK;AAAA,EAC3B,UAAU,EAAE,OAAO,EAAE,IAAI,CAAC;AAAA,EAC1B,QAAQ,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,QAAQ,CAAC;AAAA,EACxC,UAAU,EAAE,OAAO,EAAE,KAAK;AAAA,EAC1B,gBAAgB,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EAC3C,QAAQ,EAAE,OAAO,EAAE,KAAK;
|
|
4
|
+
"sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { z } from 'zod'\nimport { registerCommand, type CommandHandler } from '@open-mercato/shared/lib/commands'\nimport { extractUndoPayload, type UndoPayload } from '@open-mercato/shared/lib/commands/undo'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { Message, MessageObject, MessageRecipient } from '../data/entities'\nimport { emitMessagesEvent } from '../events'\nimport {\n findResolvedMessageActionById,\n isTerminalMessageAction,\n resolveActionCommandInput,\n resolveActionHref,\n resolveMessageActionData,\n} from '../lib/actions'\nimport { getMessageType } from '../lib/message-types-registry'\nimport { assertOrganizationAccess, type MessageScopeInput } from './shared'\n\nconst actionStateSnapshotSchema = z.object({\n actionTaken: z.string().nullable(),\n actionTakenByUserId: z.string().nullable(),\n actionTakenAt: z.string().nullable(),\n actionResult: z.record(z.string(), z.unknown()).nullable(),\n})\n\nconst recordTerminalActionSchema = z.object({\n messageId: z.string().uuid(),\n actionId: z.string().min(1),\n result: z.record(z.string(), z.unknown()),\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n userId: z.string().uuid(),\n // Optional pre-claim snapshot supplied by `messages.actions.execute` so the\n // undo log records the true (un-taken) state even though the terminal action\n // was atomically claimed before this finalizer runs.\n previousState: actionStateSnapshotSchema.optional(),\n})\n\ntype RecordTerminalActionInput = z.infer<typeof recordTerminalActionSchema>\n\nconst executeActionSchema = z.object({\n messageId: z.string().uuid(),\n actionId: z.string().min(1),\n payload: z.record(z.string(), z.unknown()).optional(),\n tenantId: z.string().uuid(),\n organizationId: z.string().uuid().nullable(),\n userId: z.string().uuid(),\n})\n\ntype ExecuteActionInput = z.infer<typeof executeActionSchema>\n\ntype ActionStateSnapshot = {\n actionTaken: string | null\n actionTakenByUserId: string | null\n actionTakenAt: string | null\n actionResult: Record<string, unknown> | null\n}\n\nfunction toIso(value: Date | null | undefined): string | null {\n return value ? value.toISOString() : null\n}\n\nfunction toDate(value: string | null | undefined): Date | null {\n if (!value) return null\n return new Date(value)\n}\n\nasync function requireActionTarget(em: EntityManager, input: RecordTerminalActionInput) {\n const message = await findOneWithDecryption(\n em,\n Message,\n {\n id: input.messageId,\n tenantId: input.tenantId,\n deletedAt: null,\n },\n undefined,\n { tenantId: input.tenantId, organizationId: input.organizationId },\n )\n if (!message) throw new Error('Message not found')\n assertOrganizationAccess(input as MessageScopeInput, message)\n\n const recipient = await em.findOne(MessageRecipient, {\n messageId: input.messageId,\n recipientUserId: input.userId,\n deletedAt: null,\n })\n if (!recipient) throw new Error('Access denied')\n return message\n}\n\nasync function requireActionMessage(em: EntityManager, input: ExecuteActionInput) {\n const message = await findOneWithDecryption(\n em,\n Message,\n {\n id: input.messageId,\n tenantId: input.tenantId,\n deletedAt: null,\n },\n undefined,\n { tenantId: input.tenantId, organizationId: input.organizationId },\n )\n if (!message) throw new Error('Message not found')\n assertOrganizationAccess(input as MessageScopeInput, message)\n const recipient = await em.findOne(MessageRecipient, {\n messageId: input.messageId,\n recipientUserId: input.userId,\n deletedAt: null,\n })\n if (!recipient) throw new Error('Access denied')\n return message\n}\n\nfunction snapshotActionState(message: Message): ActionStateSnapshot {\n return {\n actionTaken: message.actionTaken ?? null,\n actionTakenByUserId: message.actionTakenByUserId ?? null,\n actionTakenAt: toIso(message.actionTakenAt),\n actionResult: message.actionResult ?? null,\n }\n}\n\nconst recordTerminalActionCommand: CommandHandler<unknown, { ok: true }> = {\n id: 'messages.actions.record_terminal',\n async prepare(rawInput, ctx) {\n const input = recordTerminalActionSchema.parse(rawInput)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const message = await requireActionTarget(em, input)\n return { before: snapshotActionState(message) }\n },\n async execute(rawInput, ctx) {\n const input = recordTerminalActionSchema.parse(rawInput)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const message = await requireActionTarget(em, input)\n const claimedByCaller =\n message.actionTaken === input.actionId && message.actionTakenByUserId === input.userId\n if (!claimedByCaller) {\n if (message.actionTaken) {\n throw new Error('Action already taken')\n }\n // Atomic compare-and-set: only one concurrent request transitions the\n // message out of the un-taken state. nativeUpdate runs as a single\n // `UPDATE ... WHERE action_taken IS NULL` so the loser matches 0 rows.\n const claimedRows = await em.nativeUpdate(\n Message,\n { id: input.messageId, tenantId: input.tenantId, actionTaken: null, deletedAt: null },\n {\n actionTaken: input.actionId,\n actionTakenByUserId: input.userId,\n actionTakenAt: new Date(),\n },\n )\n if (claimedRows === 0) {\n throw new Error('Action already taken')\n }\n message.actionTaken = input.actionId\n message.actionTakenByUserId = input.userId\n }\n if (!message.actionTakenAt) {\n message.actionTakenAt = new Date()\n }\n // action_result is an encrypted column, so it must be written through the\n // flush path (the encryption subscriber) rather than nativeUpdate.\n message.actionResult = input.result\n await em.flush()\n return { ok: true }\n },\n async captureAfter(rawInput, _result, ctx) {\n const input = recordTerminalActionSchema.parse(rawInput)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const message = await requireActionTarget(em, input)\n return snapshotActionState(message)\n },\n buildLog: async ({ input, snapshots }) => {\n const parsed = recordTerminalActionSchema.parse(input)\n // When the action was pre-claimed by `messages.actions.execute`, the\n // prepare-captured snapshot already reflects the claimed state, so prefer\n // the explicit pre-claim snapshot for the undo baseline.\n const before =\n (parsed.previousState as ActionStateSnapshot | undefined) ??\n (snapshots.before as ActionStateSnapshot | undefined) ??\n null\n return {\n actionLabel: 'Execute message terminal action',\n resourceKind: 'messages.message',\n resourceId: parsed.messageId,\n tenantId: parsed.tenantId,\n organizationId: parsed.organizationId,\n payload: {\n undo: {\n before,\n after: (snapshots.after as ActionStateSnapshot | undefined) ?? null,\n } satisfies UndoPayload<ActionStateSnapshot>,\n },\n snapshotBefore: before,\n snapshotAfter: snapshots.after ?? null,\n }\n },\n undo: async ({ logEntry, ctx }) => {\n const undo = extractUndoPayload<UndoPayload<ActionStateSnapshot>>(logEntry)\n const before = undo?.before\n if (!before) return\n const messageId = logEntry?.resourceId as string | null\n if (!messageId) return\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const message = await findOneWithDecryption(em, Message, { id: messageId })\n if (!message) return\n message.actionTaken = before.actionTaken\n message.actionTakenByUserId = before.actionTakenByUserId\n message.actionTakenAt = toDate(before.actionTakenAt)\n message.actionResult = before.actionResult\n await em.flush()\n },\n}\n\nconst executeActionCommand: CommandHandler<\n unknown,\n { ok: true; actionId: string; result: Record<string, unknown>; operationLogEntry: unknown | null }\n> = {\n id: 'messages.actions.execute',\n async execute(rawInput, ctx) {\n const input = executeActionSchema.parse(rawInput)\n const em = (ctx.container.resolve('em') as EntityManager).fork()\n const message = await requireActionMessage(em, input)\n const objects = await em.find(MessageObject, { messageId: message.id })\n const action = findResolvedMessageActionById(message, objects, input.actionId)\n\n if (!action) {\n throw new Error('Action not found')\n }\n\n if (message.actionTaken) {\n throw Object.assign(new Error('Action already taken'), { actionTaken: message.actionTaken })\n }\n\n const shouldRecordActionTaken = isTerminalMessageAction(action)\n\n const actionData = resolveMessageActionData(message)\n if (actionData?.expiresAt) {\n if (new Date(actionData.expiresAt) < new Date()) {\n throw new Error('Actions have expired')\n }\n } else {\n const messageType = getMessageType(message.type)\n if (messageType?.actionsExpireAfterHours && message.sentAt) {\n const expiry = new Date(message.sentAt.getTime() + messageType.actionsExpireAfterHours * 60 * 60 * 1000)\n if (expiry < new Date()) {\n throw new Error('Actions have expired')\n }\n }\n }\n\n // Capture the un-taken state for the undo log before reserving the action.\n const previousState = snapshotActionState(message)\n let claimedTerminal = false\n const releaseTerminalClaim = async () => {\n if (!claimedTerminal) return\n claimedTerminal = false\n await em.nativeUpdate(\n Message,\n {\n id: message.id,\n tenantId: input.tenantId,\n actionTaken: action.id,\n actionTakenByUserId: input.userId,\n },\n { actionTaken: null, actionTakenByUserId: null, actionTakenAt: null },\n )\n }\n\n if (shouldRecordActionTaken) {\n // Atomically reserve the terminal action BEFORE running the target\n // command so concurrent requests cannot both execute it. The losing\n // request matches 0 rows and surfaces the existing 409 response.\n const claimedRows = await em.nativeUpdate(\n Message,\n { id: message.id, tenantId: input.tenantId, actionTaken: null, deletedAt: null },\n {\n actionTaken: action.id,\n actionTakenByUserId: input.userId,\n actionTakenAt: new Date(),\n },\n )\n if (claimedRows === 0) {\n const current = await findOneWithDecryption(\n em,\n Message,\n { id: message.id, tenantId: input.tenantId, deletedAt: null },\n undefined,\n { tenantId: input.tenantId, organizationId: input.organizationId },\n )\n throw Object.assign(new Error('Action already taken'), {\n actionTaken: current?.actionTaken ?? message.actionTaken ?? action.id,\n })\n }\n claimedTerminal = true\n }\n\n const commandBus = ctx.container.resolve('commandBus') as {\n execute: (\n commandId: string,\n options: { input: unknown; ctx: unknown; metadata?: unknown }\n ) => Promise<{ result: unknown; logEntry?: unknown }>\n }\n\n let result: Record<string, unknown> = {}\n let operationLogEntry: unknown | null = null\n\n if (action.commandId) {\n try {\n const actionInput = resolveActionCommandInput(\n action,\n message,\n {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n userId: input.userId,\n },\n input.payload ?? {},\n )\n\n if (actionInput.id == null) {\n const fallbackId = action.objectRef?.entityId ?? message.sourceEntityId ?? null\n if (typeof fallbackId === 'string' && fallbackId.trim().length > 0) {\n actionInput.id = fallbackId\n }\n }\n\n const commandResult = await commandBus.execute(action.commandId, {\n input: actionInput,\n ctx: {\n container: ctx.container,\n auth: {\n sub: input.userId,\n tenantId: input.tenantId,\n orgId: input.organizationId,\n },\n organizationScope: null,\n selectedOrganizationId: input.organizationId,\n organizationIds: input.organizationId ? [input.organizationId] : null,\n },\n metadata: {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n resourceKind: 'messages',\n },\n })\n\n result = (commandResult.result as Record<string, unknown>) ?? {}\n operationLogEntry = commandResult.logEntry ?? null\n } catch (err) {\n console.error('[messages] executeActionCommand sub-command failed', err)\n // The target command never completed \u2014 release the reservation so the\n // action stays retryable, matching the pre-claim failure semantics.\n await releaseTerminalClaim()\n throw new Error('Action failed')\n }\n } else if (action.href) {\n const safeRedirect = resolveActionHref(action, message, {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n userId: input.userId,\n })\n if (!safeRedirect) {\n await releaseTerminalClaim()\n throw new Error('Action has an unsafe redirect target')\n }\n result = { redirect: safeRedirect }\n } else {\n await releaseTerminalClaim()\n throw new Error('Action has no executable target')\n }\n\n if (shouldRecordActionTaken) {\n const terminalResult = await commandBus.execute('messages.actions.record_terminal', {\n input: {\n messageId: message.id,\n actionId: action.id,\n result,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n userId: input.userId,\n previousState,\n },\n ctx: {\n container: ctx.container,\n auth: ctx.auth ?? null,\n organizationScope: null,\n selectedOrganizationId: input.organizationId,\n organizationIds: input.organizationId ? [input.organizationId] : null,\n },\n })\n if (!operationLogEntry) {\n operationLogEntry = terminalResult.logEntry ?? null\n }\n await emitMessagesEvent(\n 'messages.message.action_taken',\n {\n messageId: message.id,\n actionId: action.id,\n userId: input.userId,\n recipientUserId: input.userId,\n result,\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n },\n { persistent: true }\n )\n }\n\n return {\n ok: true,\n actionId: action.id,\n result,\n operationLogEntry,\n }\n },\n}\n\nregisterCommand(recordTerminalActionCommand)\nregisterCommand(executeActionCommand)\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/core",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6094.1.28b081ea16",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -246,16 +246,16 @@
|
|
|
246
246
|
"zod": "^4.4.3"
|
|
247
247
|
},
|
|
248
248
|
"peerDependencies": {
|
|
249
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
250
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
251
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
249
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6094.1.28b081ea16",
|
|
250
|
+
"@open-mercato/shared": "0.6.6-develop.6094.1.28b081ea16",
|
|
251
|
+
"@open-mercato/ui": "0.6.6-develop.6094.1.28b081ea16",
|
|
252
252
|
"react": "^19.0.0",
|
|
253
253
|
"react-dom": "^19.0.0"
|
|
254
254
|
},
|
|
255
255
|
"devDependencies": {
|
|
256
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
257
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
258
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
256
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6094.1.28b081ea16",
|
|
257
|
+
"@open-mercato/shared": "0.6.6-develop.6094.1.28b081ea16",
|
|
258
|
+
"@open-mercato/ui": "0.6.6-develop.6094.1.28b081ea16",
|
|
259
259
|
"@testing-library/dom": "^10.4.1",
|
|
260
260
|
"@testing-library/jest-dom": "^6.9.1",
|
|
261
261
|
"@testing-library/react": "^16.3.1",
|
|
@@ -15,6 +15,13 @@ import {
|
|
|
15
15
|
import { getMessageType } from '../lib/message-types-registry'
|
|
16
16
|
import { assertOrganizationAccess, type MessageScopeInput } from './shared'
|
|
17
17
|
|
|
18
|
+
const actionStateSnapshotSchema = z.object({
|
|
19
|
+
actionTaken: z.string().nullable(),
|
|
20
|
+
actionTakenByUserId: z.string().nullable(),
|
|
21
|
+
actionTakenAt: z.string().nullable(),
|
|
22
|
+
actionResult: z.record(z.string(), z.unknown()).nullable(),
|
|
23
|
+
})
|
|
24
|
+
|
|
18
25
|
const recordTerminalActionSchema = z.object({
|
|
19
26
|
messageId: z.string().uuid(),
|
|
20
27
|
actionId: z.string().min(1),
|
|
@@ -22,6 +29,10 @@ const recordTerminalActionSchema = z.object({
|
|
|
22
29
|
tenantId: z.string().uuid(),
|
|
23
30
|
organizationId: z.string().uuid().nullable(),
|
|
24
31
|
userId: z.string().uuid(),
|
|
32
|
+
// Optional pre-claim snapshot supplied by `messages.actions.execute` so the
|
|
33
|
+
// undo log records the true (un-taken) state even though the terminal action
|
|
34
|
+
// was atomically claimed before this finalizer runs.
|
|
35
|
+
previousState: actionStateSnapshotSchema.optional(),
|
|
25
36
|
})
|
|
26
37
|
|
|
27
38
|
type RecordTerminalActionInput = z.infer<typeof recordTerminalActionSchema>
|
|
@@ -121,12 +132,35 @@ const recordTerminalActionCommand: CommandHandler<unknown, { ok: true }> = {
|
|
|
121
132
|
const input = recordTerminalActionSchema.parse(rawInput)
|
|
122
133
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
123
134
|
const message = await requireActionTarget(em, input)
|
|
124
|
-
|
|
125
|
-
|
|
135
|
+
const claimedByCaller =
|
|
136
|
+
message.actionTaken === input.actionId && message.actionTakenByUserId === input.userId
|
|
137
|
+
if (!claimedByCaller) {
|
|
138
|
+
if (message.actionTaken) {
|
|
139
|
+
throw new Error('Action already taken')
|
|
140
|
+
}
|
|
141
|
+
// Atomic compare-and-set: only one concurrent request transitions the
|
|
142
|
+
// message out of the un-taken state. nativeUpdate runs as a single
|
|
143
|
+
// `UPDATE ... WHERE action_taken IS NULL` so the loser matches 0 rows.
|
|
144
|
+
const claimedRows = await em.nativeUpdate(
|
|
145
|
+
Message,
|
|
146
|
+
{ id: input.messageId, tenantId: input.tenantId, actionTaken: null, deletedAt: null },
|
|
147
|
+
{
|
|
148
|
+
actionTaken: input.actionId,
|
|
149
|
+
actionTakenByUserId: input.userId,
|
|
150
|
+
actionTakenAt: new Date(),
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
if (claimedRows === 0) {
|
|
154
|
+
throw new Error('Action already taken')
|
|
155
|
+
}
|
|
156
|
+
message.actionTaken = input.actionId
|
|
157
|
+
message.actionTakenByUserId = input.userId
|
|
158
|
+
}
|
|
159
|
+
if (!message.actionTakenAt) {
|
|
160
|
+
message.actionTakenAt = new Date()
|
|
126
161
|
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
message.actionTakenAt = new Date()
|
|
162
|
+
// action_result is an encrypted column, so it must be written through the
|
|
163
|
+
// flush path (the encryption subscriber) rather than nativeUpdate.
|
|
130
164
|
message.actionResult = input.result
|
|
131
165
|
await em.flush()
|
|
132
166
|
return { ok: true }
|
|
@@ -139,6 +173,13 @@ const recordTerminalActionCommand: CommandHandler<unknown, { ok: true }> = {
|
|
|
139
173
|
},
|
|
140
174
|
buildLog: async ({ input, snapshots }) => {
|
|
141
175
|
const parsed = recordTerminalActionSchema.parse(input)
|
|
176
|
+
// When the action was pre-claimed by `messages.actions.execute`, the
|
|
177
|
+
// prepare-captured snapshot already reflects the claimed state, so prefer
|
|
178
|
+
// the explicit pre-claim snapshot for the undo baseline.
|
|
179
|
+
const before =
|
|
180
|
+
(parsed.previousState as ActionStateSnapshot | undefined) ??
|
|
181
|
+
(snapshots.before as ActionStateSnapshot | undefined) ??
|
|
182
|
+
null
|
|
142
183
|
return {
|
|
143
184
|
actionLabel: 'Execute message terminal action',
|
|
144
185
|
resourceKind: 'messages.message',
|
|
@@ -147,11 +188,11 @@ const recordTerminalActionCommand: CommandHandler<unknown, { ok: true }> = {
|
|
|
147
188
|
organizationId: parsed.organizationId,
|
|
148
189
|
payload: {
|
|
149
190
|
undo: {
|
|
150
|
-
before
|
|
191
|
+
before,
|
|
151
192
|
after: (snapshots.after as ActionStateSnapshot | undefined) ?? null,
|
|
152
193
|
} satisfies UndoPayload<ActionStateSnapshot>,
|
|
153
194
|
},
|
|
154
|
-
snapshotBefore:
|
|
195
|
+
snapshotBefore: before,
|
|
155
196
|
snapshotAfter: snapshots.after ?? null,
|
|
156
197
|
}
|
|
157
198
|
},
|
|
@@ -209,6 +250,52 @@ const executeActionCommand: CommandHandler<
|
|
|
209
250
|
}
|
|
210
251
|
}
|
|
211
252
|
|
|
253
|
+
// Capture the un-taken state for the undo log before reserving the action.
|
|
254
|
+
const previousState = snapshotActionState(message)
|
|
255
|
+
let claimedTerminal = false
|
|
256
|
+
const releaseTerminalClaim = async () => {
|
|
257
|
+
if (!claimedTerminal) return
|
|
258
|
+
claimedTerminal = false
|
|
259
|
+
await em.nativeUpdate(
|
|
260
|
+
Message,
|
|
261
|
+
{
|
|
262
|
+
id: message.id,
|
|
263
|
+
tenantId: input.tenantId,
|
|
264
|
+
actionTaken: action.id,
|
|
265
|
+
actionTakenByUserId: input.userId,
|
|
266
|
+
},
|
|
267
|
+
{ actionTaken: null, actionTakenByUserId: null, actionTakenAt: null },
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (shouldRecordActionTaken) {
|
|
272
|
+
// Atomically reserve the terminal action BEFORE running the target
|
|
273
|
+
// command so concurrent requests cannot both execute it. The losing
|
|
274
|
+
// request matches 0 rows and surfaces the existing 409 response.
|
|
275
|
+
const claimedRows = await em.nativeUpdate(
|
|
276
|
+
Message,
|
|
277
|
+
{ id: message.id, tenantId: input.tenantId, actionTaken: null, deletedAt: null },
|
|
278
|
+
{
|
|
279
|
+
actionTaken: action.id,
|
|
280
|
+
actionTakenByUserId: input.userId,
|
|
281
|
+
actionTakenAt: new Date(),
|
|
282
|
+
},
|
|
283
|
+
)
|
|
284
|
+
if (claimedRows === 0) {
|
|
285
|
+
const current = await findOneWithDecryption(
|
|
286
|
+
em,
|
|
287
|
+
Message,
|
|
288
|
+
{ id: message.id, tenantId: input.tenantId, deletedAt: null },
|
|
289
|
+
undefined,
|
|
290
|
+
{ tenantId: input.tenantId, organizationId: input.organizationId },
|
|
291
|
+
)
|
|
292
|
+
throw Object.assign(new Error('Action already taken'), {
|
|
293
|
+
actionTaken: current?.actionTaken ?? message.actionTaken ?? action.id,
|
|
294
|
+
})
|
|
295
|
+
}
|
|
296
|
+
claimedTerminal = true
|
|
297
|
+
}
|
|
298
|
+
|
|
212
299
|
const commandBus = ctx.container.resolve('commandBus') as {
|
|
213
300
|
execute: (
|
|
214
301
|
commandId: string,
|
|
@@ -263,6 +350,9 @@ const executeActionCommand: CommandHandler<
|
|
|
263
350
|
operationLogEntry = commandResult.logEntry ?? null
|
|
264
351
|
} catch (err) {
|
|
265
352
|
console.error('[messages] executeActionCommand sub-command failed', err)
|
|
353
|
+
// The target command never completed — release the reservation so the
|
|
354
|
+
// action stays retryable, matching the pre-claim failure semantics.
|
|
355
|
+
await releaseTerminalClaim()
|
|
266
356
|
throw new Error('Action failed')
|
|
267
357
|
}
|
|
268
358
|
} else if (action.href) {
|
|
@@ -272,10 +362,12 @@ const executeActionCommand: CommandHandler<
|
|
|
272
362
|
userId: input.userId,
|
|
273
363
|
})
|
|
274
364
|
if (!safeRedirect) {
|
|
365
|
+
await releaseTerminalClaim()
|
|
275
366
|
throw new Error('Action has an unsafe redirect target')
|
|
276
367
|
}
|
|
277
368
|
result = { redirect: safeRedirect }
|
|
278
369
|
} else {
|
|
370
|
+
await releaseTerminalClaim()
|
|
279
371
|
throw new Error('Action has no executable target')
|
|
280
372
|
}
|
|
281
373
|
|
|
@@ -288,6 +380,7 @@ const executeActionCommand: CommandHandler<
|
|
|
288
380
|
tenantId: input.tenantId,
|
|
289
381
|
organizationId: input.organizationId,
|
|
290
382
|
userId: input.userId,
|
|
383
|
+
previousState,
|
|
291
384
|
},
|
|
292
385
|
ctx: {
|
|
293
386
|
container: ctx.container,
|