@open-mercato/core 0.6.3-develop.3876.1.d40fe4ec2d → 0.6.3-develop.3894.1.352abf4240
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/attachments/api/file/[id]/route.js +7 -2
- package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +7 -4
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
- package/dist/modules/audit_logs/services/accessLogService.js +127 -8
- package/dist/modules/audit_logs/services/accessLogService.js.map +2 -2
- package/dist/modules/auth/backend/auth/profile/page.js +1 -1
- package/dist/modules/auth/backend/auth/profile/page.js.map +2 -2
- package/dist/modules/auth/backend/profile/change-password/page.js +1 -1
- package/dist/modules/auth/backend/profile/change-password/page.js.map +2 -2
- package/dist/modules/auth/backend/users/[id]/edit/page.js +1 -1
- package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
- package/dist/modules/auth/backend/users/create/page.js +6 -1
- package/dist/modules/auth/backend/users/create/page.js.map +2 -2
- package/dist/modules/auth/di.js +17 -3
- package/dist/modules/auth/di.js.map +2 -2
- package/dist/modules/auth/services/rbacDefaultCache.js +110 -0
- package/dist/modules/auth/services/rbacDefaultCache.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js +8 -1
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js +3 -2
- package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/[productId]/variants/create/page.js +3 -2
- package/dist/modules/catalog/backend/catalog/products/[productId]/variants/create/page.js.map +2 -2
- package/dist/modules/configs/cli.js +27 -14
- package/dist/modules/configs/cli.js.map +2 -2
- package/dist/modules/currencies/api/currencies/route.js +3 -4
- package/dist/modules/currencies/api/currencies/route.js.map +2 -2
- package/dist/modules/currencies/api/exchange-rates/route.js +3 -4
- package/dist/modules/currencies/api/exchange-rates/route.js.map +2 -2
- package/dist/modules/customers/api/people/route.js +26 -24
- package/dist/modules/customers/api/people/route.js.map +2 -2
- package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js +26 -0
- package/dist/modules/directory/subscribers/invalidateOrgScopeCache.js.map +7 -0
- package/dist/modules/directory/utils/organizationScope.js +85 -0
- package/dist/modules/directory/utils/organizationScope.js.map +2 -2
- package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js +1 -1
- package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js.map +2 -2
- package/dist/modules/sales/backend/sales/channels/[channelId]/edit/page.js +1 -1
- package/dist/modules/sales/backend/sales/channels/[channelId]/edit/page.js.map +2 -2
- package/dist/modules/sales/components/channels/ChannelOfferForm.js +1 -1
- package/dist/modules/sales/components/channels/ChannelOfferForm.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/[id]/page.js +2 -1
- package/dist/modules/workflows/backend/definitions/[id]/page.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/create/page.js +4 -2
- package/dist/modules/workflows/backend/definitions/create/page.js.map +2 -2
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js +20 -3
- package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
- package/dist/modules/workflows/components/ActivitiesEditor.js +34 -1
- package/dist/modules/workflows/components/ActivitiesEditor.js.map +2 -2
- package/dist/modules/workflows/components/NodeEditDialog.js +153 -17
- package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
- package/dist/modules/workflows/components/StepsEditor.js +31 -0
- package/dist/modules/workflows/components/StepsEditor.js.map +2 -2
- package/dist/modules/workflows/components/WorkflowGraph.js +3 -2
- package/dist/modules/workflows/components/WorkflowGraph.js.map +2 -2
- package/dist/modules/workflows/components/nodes/WaitForTimerNode.js +54 -0
- package/dist/modules/workflows/components/nodes/WaitForTimerNode.js.map +7 -0
- package/dist/modules/workflows/components/nodes/index.js +3 -1
- package/dist/modules/workflows/components/nodes/index.js.map +2 -2
- package/dist/modules/workflows/data/validators.js +117 -0
- package/dist/modules/workflows/data/validators.js.map +2 -2
- package/dist/modules/workflows/di.js +5 -1
- package/dist/modules/workflows/di.js.map +2 -2
- package/dist/modules/workflows/lib/activity-executor.js +42 -1
- package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
- package/dist/modules/workflows/lib/activity-queue-types.js.map +2 -2
- package/dist/modules/workflows/lib/activity-worker-handler.js +24 -0
- package/dist/modules/workflows/lib/activity-worker-handler.js.map +2 -2
- package/dist/modules/workflows/lib/duration.js +32 -0
- package/dist/modules/workflows/lib/duration.js.map +7 -0
- package/dist/modules/workflows/lib/event-logger.js +1 -0
- package/dist/modules/workflows/lib/event-logger.js.map +2 -2
- package/dist/modules/workflows/lib/format-validation-error.js +12 -0
- package/dist/modules/workflows/lib/format-validation-error.js.map +7 -0
- package/dist/modules/workflows/lib/graph-utils.js +6 -3
- package/dist/modules/workflows/lib/graph-utils.js.map +2 -2
- package/dist/modules/workflows/lib/node-type-icons.js +9 -5
- package/dist/modules/workflows/lib/node-type-icons.js.map +2 -2
- package/dist/modules/workflows/lib/signal-handler.js +55 -23
- package/dist/modules/workflows/lib/signal-handler.js.map +2 -2
- package/dist/modules/workflows/lib/step-handler.js +79 -29
- package/dist/modules/workflows/lib/step-handler.js.map +2 -2
- package/dist/modules/workflows/lib/timer-handler.js +159 -0
- package/dist/modules/workflows/lib/timer-handler.js.map +7 -0
- package/dist/modules/workflows/lib/workflow-executor.js +1 -1
- package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
- package/dist/modules/workflows/workers/workflow-activities.worker.js +20 -4
- package/dist/modules/workflows/workers/workflow-activities.worker.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/attachments/api/file/[id]/route.ts +7 -2
- package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +7 -4
- package/src/modules/audit_logs/services/accessLogService.ts +179 -15
- package/src/modules/auth/backend/auth/profile/page.tsx +1 -1
- package/src/modules/auth/backend/profile/change-password/page.tsx +1 -1
- package/src/modules/auth/backend/users/[id]/edit/page.tsx +1 -1
- package/src/modules/auth/backend/users/create/page.tsx +6 -1
- package/src/modules/auth/di.ts +26 -3
- package/src/modules/auth/services/rbacDefaultCache.ts +145 -0
- package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +8 -1
- package/src/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.tsx +3 -2
- package/src/modules/catalog/backend/catalog/products/[productId]/variants/create/page.tsx +3 -2
- package/src/modules/configs/cli.ts +34 -13
- package/src/modules/currencies/api/currencies/route.ts +3 -4
- package/src/modules/currencies/api/exchange-rates/route.ts +3 -4
- package/src/modules/customers/api/people/route.ts +27 -25
- package/src/modules/directory/subscribers/invalidateOrgScopeCache.ts +39 -0
- package/src/modules/directory/utils/organizationScope.ts +121 -0
- package/src/modules/resources/backend/resources/resource-types/[id]/edit/page.tsx +1 -1
- package/src/modules/sales/backend/sales/channels/[channelId]/edit/page.tsx +1 -1
- package/src/modules/sales/components/channels/ChannelOfferForm.tsx +1 -1
- package/src/modules/workflows/backend/definitions/[id]/page.tsx +3 -2
- package/src/modules/workflows/backend/definitions/create/page.tsx +4 -2
- package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +18 -1
- package/src/modules/workflows/components/ActivitiesEditor.tsx +40 -0
- package/src/modules/workflows/components/NodeEditDialog.tsx +218 -30
- package/src/modules/workflows/components/StepsEditor.tsx +36 -0
- package/src/modules/workflows/components/WorkflowGraph.tsx +2 -1
- package/src/modules/workflows/components/nodes/WaitForTimerNode.tsx +70 -0
- package/src/modules/workflows/components/nodes/index.ts +3 -0
- package/src/modules/workflows/data/validators.ts +121 -0
- package/src/modules/workflows/di.ts +4 -0
- package/src/modules/workflows/i18n/de.json +10 -1
- package/src/modules/workflows/i18n/en.json +10 -1
- package/src/modules/workflows/i18n/es.json +10 -1
- package/src/modules/workflows/i18n/pl.json +10 -1
- package/src/modules/workflows/lib/activity-executor.ts +86 -2
- package/src/modules/workflows/lib/activity-queue-types.ts +18 -11
- package/src/modules/workflows/lib/activity-worker-handler.ts +29 -0
- package/src/modules/workflows/lib/duration.ts +51 -0
- package/src/modules/workflows/lib/event-logger.ts +1 -0
- package/src/modules/workflows/lib/format-validation-error.ts +30 -0
- package/src/modules/workflows/lib/graph-utils.ts +3 -0
- package/src/modules/workflows/lib/node-type-icons.ts +6 -2
- package/src/modules/workflows/lib/signal-handler.ts +62 -24
- package/src/modules/workflows/lib/step-handler.ts +107 -50
- package/src/modules/workflows/lib/timer-handler.ts +213 -0
- package/src/modules/workflows/lib/workflow-executor.ts +1 -1
- package/src/modules/workflows/workers/workflow-activities.worker.ts +33 -7
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { WORKFLOW_ACTIVITIES_QUEUE_NAME } from "../lib/activity-queue-types.js";
|
|
2
1
|
import { WorkflowInstance } from "../data/entities.js";
|
|
3
2
|
import { logWorkflowEvent } from "../lib/event-logger.js";
|
|
4
3
|
import {
|
|
@@ -9,21 +8,36 @@ import {
|
|
|
9
8
|
executeCallWebhook,
|
|
10
9
|
executeFunction
|
|
11
10
|
} from "../lib/activity-executor.js";
|
|
11
|
+
const WORKFLOW_ACTIVITIES_QUEUE = "workflow-activities";
|
|
12
12
|
const DEFAULT_CONCURRENCY = 1;
|
|
13
13
|
const envConcurrency = process.env.WORKERS_WORKFLOW_ACTIVITIES_CONCURRENCY;
|
|
14
14
|
const metadata = {
|
|
15
|
-
queue:
|
|
15
|
+
queue: WORKFLOW_ACTIVITIES_QUEUE,
|
|
16
16
|
id: "workflows:workflow-activities",
|
|
17
17
|
concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY
|
|
18
18
|
};
|
|
19
19
|
async function handle(job, ctx) {
|
|
20
20
|
const { payload } = job;
|
|
21
21
|
const startTime = Date.now();
|
|
22
|
+
const em = ctx.resolve("em");
|
|
23
|
+
const container = ctx;
|
|
24
|
+
if (payload.kind === "timer") {
|
|
25
|
+
console.log(
|
|
26
|
+
`[workflows:activity-worker] Firing timer for instance ${payload.workflowInstanceId} (job ${ctx.jobId})`
|
|
27
|
+
);
|
|
28
|
+
const { fireTimer } = await import("../lib/timer-handler.js");
|
|
29
|
+
await fireTimer(em, container, {
|
|
30
|
+
instanceId: payload.workflowInstanceId,
|
|
31
|
+
stepInstanceId: payload.stepInstanceId,
|
|
32
|
+
tenantId: payload.tenantId,
|
|
33
|
+
organizationId: payload.organizationId,
|
|
34
|
+
userId: payload.userId
|
|
35
|
+
});
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
22
38
|
console.log(
|
|
23
39
|
`[workflows:activity-worker] Processing activity ${payload.activityId} (${payload.activityType}) for workflow instance ${payload.workflowInstanceId} (job ${ctx.jobId}, attempt ${ctx.attemptNumber})`
|
|
24
40
|
);
|
|
25
|
-
const em = ctx.resolve("em");
|
|
26
|
-
const container = ctx;
|
|
27
41
|
try {
|
|
28
42
|
const instance = await em.findOne(WorkflowInstance, {
|
|
29
43
|
id: payload.workflowInstanceId,
|
|
@@ -67,6 +81,8 @@ async function handle(job, ctx) {
|
|
|
67
81
|
return await executeCallWebhook(payload.activityConfig, activityContext, { signal });
|
|
68
82
|
case "EXECUTE_FUNCTION":
|
|
69
83
|
return await executeFunction(payload.activityConfig, activityContext, container);
|
|
84
|
+
case "WAIT":
|
|
85
|
+
return { waited: true };
|
|
70
86
|
default:
|
|
71
87
|
throw new Error(`Unsupported activity type: ${payload.activityType}`);
|
|
72
88
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/workflows/workers/workflow-activities.worker.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Workflow Activity Worker\n *\n * Background worker that processes async activities from the workflow queue.\n * Executes activities with timeout, logs events, and triggers workflow resume.\n *\n * This worker is auto-discovered by the queue system and processes jobs from\n * the 'workflow-activities' queue.\n */\n\nimport type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport { WORKFLOW_ACTIVITIES_QUEUE_NAME, type WorkflowActivityJob } from '../lib/activity-queue-types'\nimport type { EntityManager } from '@mikro-orm/core'\nimport type { AwilixContainer } from 'awilix'\nimport { WorkflowInstance } from '../data/entities'\nimport { logWorkflowEvent } from '../lib/event-logger'\nimport {\n executeSendEmail,\n executeCallApi,\n executeEmitEvent,\n executeUpdateEntity,\n executeCallWebhook,\n executeFunction,\n} from '../lib/activity-executor'\n\n// Worker metadata for auto-discovery\nconst DEFAULT_CONCURRENCY = 1\nconst envConcurrency = process.env.WORKERS_WORKFLOW_ACTIVITIES_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: WORKFLOW_ACTIVITIES_QUEUE_NAME,\n id: 'workflows:workflow-activities',\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\n/**\n * Process a workflow activity job.\n *\n * This handler:\n * 1. Fetches the workflow instance\n * 2. Executes the activity by type with timeout support\n * 3. Logs success/failure events to workflow event log\n * 4. Attempts to resume the workflow if all activities complete\n *\n * @param job - The queued job containing activity payload\n * @param ctx - Job context with DI container access\n */\nexport default async function handle(\n job: QueuedJob<WorkflowActivityJob>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n const { payload } = job\n const startTime = Date.now()\n\n console.log(\n `[workflows:activity-worker] Processing activity ${payload.activityId} (${payload.activityType}) for workflow instance ${payload.workflowInstanceId} (job ${ctx.jobId}, attempt ${ctx.attemptNumber})`\n )\n\n // Resolve services from DI container\n const em = ctx.resolve<EntityManager>('em')\n\n // Create a container-like object from ctx.resolve for activity executors\n // The ctx already has the resolve method we need, we just need to cast it\n const container = ctx as unknown as AwilixContainer\n\n try {\n // Fetch workflow instance with tenant/org scoping\n const instance = await em.findOne(WorkflowInstance, {\n id: payload.workflowInstanceId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n if (!instance) {\n throw new Error(\n `Workflow instance ${payload.workflowInstanceId} not found (tenant: ${payload.tenantId}, org: ${payload.organizationId})`\n )\n }\n\n // Build activity execution context\n const activityContext = {\n workflowInstance: instance,\n workflowContext: payload.workflowContext,\n stepContext: payload.stepContext,\n stepInstanceId: payload.stepInstanceId,\n userId: payload.userId,\n }\n\n // Execute activity by type\n const executeActivityByType = async (signal?: AbortSignal) => {\n switch (payload.activityType) {\n case 'SEND_EMAIL':\n return await executeSendEmail(payload.activityConfig, activityContext, container)\n case 'CALL_API':\n return await executeCallApi(\n em,\n payload.activityConfig,\n activityContext,\n container,\n signal\n )\n case 'EMIT_EVENT':\n return await executeEmitEvent(payload.activityConfig, activityContext, container)\n case 'UPDATE_ENTITY':\n return await executeUpdateEntity(\n em,\n payload.activityConfig,\n activityContext,\n container\n )\n case 'CALL_WEBHOOK':\n return await executeCallWebhook(payload.activityConfig, activityContext, { signal })\n case 'EXECUTE_FUNCTION':\n return await executeFunction(payload.activityConfig, activityContext, container)\n default:\n throw new Error(`Unsupported activity type: ${payload.activityType}`)\n }\n }\n\n // Execute with optional timeout. AbortController aborts in-flight fetches\n // when the timeout wins the race, preventing phantom executions.\n let result: any\n if (payload.timeoutMs && payload.timeoutMs > 0) {\n const abortController = new AbortController()\n const timeoutId = setTimeout(() => {\n abortController.abort()\n }, payload.timeoutMs)\n\n try {\n result = await Promise.race([\n executeActivityByType(abortController.signal),\n new Promise((_, reject) =>\n setTimeout(\n () => reject(new Error(`Activity timeout after ${payload.timeoutMs}ms`)),\n payload.timeoutMs\n )\n ),\n ])\n } finally {\n clearTimeout(timeoutId)\n }\n } else {\n result = await executeActivityByType()\n }\n\n const executionTimeMs = Date.now() - startTime\n\n // Log success event to workflow event log\n await logWorkflowEvent(em, {\n workflowInstanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n eventType: 'ACTIVITY_COMPLETED',\n eventData: {\n activityId: payload.activityId,\n activityName: payload.activityName,\n activityType: payload.activityType,\n async: true,\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n executionTimeMs,\n output: result,\n },\n userId: payload.userId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n console.log(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) completed successfully for workflow instance ${payload.workflowInstanceId} in ${executionTimeMs}ms`\n )\n\n // Attempt to resume workflow if all activities complete\n await checkAndResumeWorkflow(em, ctx, payload.workflowInstanceId)\n } catch (error: any) {\n const executionTimeMs = Date.now() - startTime\n\n console.error(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) failed for workflow instance ${payload.workflowInstanceId} (attempt ${ctx.attemptNumber}):`,\n error.message\n )\n\n // Log failure event to workflow event log\n await logWorkflowEvent(em, {\n workflowInstanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n eventType: 'ACTIVITY_FAILED',\n eventData: {\n activityId: payload.activityId,\n activityName: payload.activityName,\n activityType: payload.activityType,\n async: true,\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n error: error.message,\n errorStack: error.stack,\n executionTimeMs,\n },\n userId: payload.userId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n // Check if this was final attempt (BullMQ handles retries automatically)\n const maxAttempts = payload.retryPolicy?.maxAttempts || 1\n if (ctx.attemptNumber >= maxAttempts) {\n console.error(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) failed after ${maxAttempts} attempts for workflow instance ${payload.workflowInstanceId} - triggering workflow failure handling`\n )\n // Final failure - attempt to resume workflow (may transition to FAILED state)\n await checkAndResumeWorkflow(em, ctx, payload.workflowInstanceId)\n }\n\n // Re-throw to let BullMQ handle retry logic\n throw error\n }\n}\n\n/**\n * Helper to check if workflow can resume after activities complete/fail.\n *\n * This function is called after each activity completes or fails.\n * It checks if all pending activities are done and resumes workflow execution.\n *\n * @param em - Entity manager\n * @param ctx - Handler context with resolve method for DI\n * @param workflowInstanceId - Workflow instance ID to resume\n */\nasync function checkAndResumeWorkflow(\n em: EntityManager,\n ctx: HandlerContext,\n workflowInstanceId: string\n): Promise<void> {\n // Import here to avoid circular dependency\n const { resumeWorkflowAfterActivities } = await import('../lib/workflow-executor')\n\n // Cast ctx to AwilixContainer for the resume function\n const container = ctx as unknown as AwilixContainer\n\n try {\n await resumeWorkflowAfterActivities(em, container, workflowInstanceId)\n } catch (error: any) {\n // Ignore error if workflow not ready to resume yet (activities still pending)\n if (!error.message?.includes('Activities still pending')) {\n console.error(\n `[workflows:activity-worker] Failed to resume workflow instance ${workflowInstanceId}:`,\n error.message\n )\n }\n }\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["/**\n * Workflow Activity Worker\n *\n * Background worker that processes async activities from the workflow queue.\n * Executes activities with timeout, logs events, and triggers workflow resume.\n *\n * This worker is auto-discovered by the queue system and processes jobs from\n * the 'workflow-activities' queue.\n */\n\nimport type { QueuedJob, JobContext, WorkerMeta } from '@open-mercato/queue'\nimport type { WorkflowActivityJob } from '../lib/activity-queue-types'\nimport type { EntityManager } from '@mikro-orm/core'\nimport type { AwilixContainer } from 'awilix'\nimport { WorkflowInstance } from '../data/entities'\nimport { logWorkflowEvent } from '../lib/event-logger'\nimport {\n executeSendEmail,\n executeCallApi,\n executeEmitEvent,\n executeUpdateEntity,\n executeCallWebhook,\n executeFunction,\n} from '../lib/activity-executor'\n\n// Worker metadata for auto-discovery.\n// NOTE: `queue` MUST be a string literal (or locally-declared const) so the\n// generator's AST-based extractor can resolve it when Node cannot import the\n// .ts source file directly. Importing `WORKFLOW_ACTIVITIES_QUEUE_NAME` from\n// another module breaks auto-discovery and silently drops the worker from\n// `modules.generated.ts`.\nconst WORKFLOW_ACTIVITIES_QUEUE = 'workflow-activities'\nconst DEFAULT_CONCURRENCY = 1\nconst envConcurrency = process.env.WORKERS_WORKFLOW_ACTIVITIES_CONCURRENCY\n\nexport const metadata: WorkerMeta = {\n queue: WORKFLOW_ACTIVITIES_QUEUE,\n id: 'workflows:workflow-activities',\n concurrency: envConcurrency ? parseInt(envConcurrency, 10) : DEFAULT_CONCURRENCY,\n}\n\ntype HandlerContext = { resolve: <T = unknown>(name: string) => T }\n\n/**\n * Process a workflow activity job.\n *\n * This handler:\n * 1. Fetches the workflow instance\n * 2. Executes the activity by type with timeout support\n * 3. Logs success/failure events to workflow event log\n * 4. Attempts to resume the workflow if all activities complete\n *\n * @param job - The queued job containing activity payload\n * @param ctx - Job context with DI container access\n */\nexport default async function handle(\n job: QueuedJob<WorkflowActivityJob>,\n ctx: JobContext & HandlerContext\n): Promise<void> {\n const { payload } = job\n const startTime = Date.now()\n\n // Resolve services from DI container\n const em = ctx.resolve<EntityManager>('em')\n\n // Create a container-like object from ctx.resolve for activity executors\n // The ctx already has the resolve method we need, we just need to cast it\n const container = ctx as unknown as AwilixContainer\n\n // Timer jobs (kind: 'timer') are a distinct flow \u2014 they resume a paused\n // workflow at a WAIT_FOR_TIMER step rather than running an activity.\n if (payload.kind === 'timer') {\n console.log(\n `[workflows:activity-worker] Firing timer for instance ${payload.workflowInstanceId} (job ${ctx.jobId})`\n )\n const { fireTimer } = await import('../lib/timer-handler')\n await fireTimer(em, container, {\n instanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n userId: payload.userId,\n })\n return\n }\n\n console.log(\n `[workflows:activity-worker] Processing activity ${payload.activityId} (${payload.activityType}) for workflow instance ${payload.workflowInstanceId} (job ${ctx.jobId}, attempt ${ctx.attemptNumber})`\n )\n\n try {\n // Fetch workflow instance with tenant/org scoping\n const instance = await em.findOne(WorkflowInstance, {\n id: payload.workflowInstanceId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n if (!instance) {\n throw new Error(\n `Workflow instance ${payload.workflowInstanceId} not found (tenant: ${payload.tenantId}, org: ${payload.organizationId})`\n )\n }\n\n // Build activity execution context\n const activityContext = {\n workflowInstance: instance,\n workflowContext: payload.workflowContext,\n stepContext: payload.stepContext,\n stepInstanceId: payload.stepInstanceId,\n userId: payload.userId,\n }\n\n // Execute activity by type\n const executeActivityByType = async (signal?: AbortSignal) => {\n switch (payload.activityType) {\n case 'SEND_EMAIL':\n return await executeSendEmail(payload.activityConfig, activityContext, container)\n case 'CALL_API':\n return await executeCallApi(\n em,\n payload.activityConfig,\n activityContext,\n container,\n signal\n )\n case 'EMIT_EVENT':\n return await executeEmitEvent(payload.activityConfig, activityContext, container)\n case 'UPDATE_ENTITY':\n return await executeUpdateEntity(\n em,\n payload.activityConfig,\n activityContext,\n container\n )\n case 'CALL_WEBHOOK':\n return await executeCallWebhook(payload.activityConfig, activityContext, { signal })\n case 'EXECUTE_FUNCTION':\n return await executeFunction(payload.activityConfig, activityContext, container)\n case 'WAIT':\n // Delay already handled by queue's delayMs \u2014 return success immediately\n return { waited: true }\n default:\n throw new Error(`Unsupported activity type: ${payload.activityType}`)\n }\n }\n\n // Execute with optional timeout. AbortController aborts in-flight fetches\n // when the timeout wins the race, preventing phantom executions.\n let result: any\n if (payload.timeoutMs && payload.timeoutMs > 0) {\n const abortController = new AbortController()\n const timeoutId = setTimeout(() => {\n abortController.abort()\n }, payload.timeoutMs)\n\n try {\n result = await Promise.race([\n executeActivityByType(abortController.signal),\n new Promise((_, reject) =>\n setTimeout(\n () => reject(new Error(`Activity timeout after ${payload.timeoutMs}ms`)),\n payload.timeoutMs\n )\n ),\n ])\n } finally {\n clearTimeout(timeoutId)\n }\n } else {\n result = await executeActivityByType()\n }\n\n const executionTimeMs = Date.now() - startTime\n\n // Log success event to workflow event log\n await logWorkflowEvent(em, {\n workflowInstanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n eventType: 'ACTIVITY_COMPLETED',\n eventData: {\n activityId: payload.activityId,\n activityName: payload.activityName,\n activityType: payload.activityType,\n async: true,\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n executionTimeMs,\n output: result,\n },\n userId: payload.userId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n console.log(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) completed successfully for workflow instance ${payload.workflowInstanceId} in ${executionTimeMs}ms`\n )\n\n // Attempt to resume workflow if all activities complete\n await checkAndResumeWorkflow(em, ctx, payload.workflowInstanceId)\n } catch (error: any) {\n const executionTimeMs = Date.now() - startTime\n\n console.error(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) failed for workflow instance ${payload.workflowInstanceId} (attempt ${ctx.attemptNumber}):`,\n error.message\n )\n\n // Log failure event to workflow event log\n await logWorkflowEvent(em, {\n workflowInstanceId: payload.workflowInstanceId,\n stepInstanceId: payload.stepInstanceId,\n eventType: 'ACTIVITY_FAILED',\n eventData: {\n activityId: payload.activityId,\n activityName: payload.activityName,\n activityType: payload.activityType,\n async: true,\n jobId: ctx.jobId,\n attemptNumber: ctx.attemptNumber,\n error: error.message,\n errorStack: error.stack,\n executionTimeMs,\n },\n userId: payload.userId,\n tenantId: payload.tenantId,\n organizationId: payload.organizationId,\n })\n\n // Check if this was final attempt (BullMQ handles retries automatically)\n const maxAttempts = payload.retryPolicy?.maxAttempts || 1\n if (ctx.attemptNumber >= maxAttempts) {\n console.error(\n `[workflows:activity-worker] Activity ${payload.activityId} (${payload.activityType}) failed after ${maxAttempts} attempts for workflow instance ${payload.workflowInstanceId} - triggering workflow failure handling`\n )\n // Final failure - attempt to resume workflow (may transition to FAILED state)\n await checkAndResumeWorkflow(em, ctx, payload.workflowInstanceId)\n }\n\n // Re-throw to let BullMQ handle retry logic\n throw error\n }\n}\n\n/**\n * Helper to check if workflow can resume after activities complete/fail.\n *\n * This function is called after each activity completes or fails.\n * It checks if all pending activities are done and resumes workflow execution.\n *\n * @param em - Entity manager\n * @param ctx - Handler context with resolve method for DI\n * @param workflowInstanceId - Workflow instance ID to resume\n */\nasync function checkAndResumeWorkflow(\n em: EntityManager,\n ctx: HandlerContext,\n workflowInstanceId: string\n): Promise<void> {\n // Import here to avoid circular dependency\n const { resumeWorkflowAfterActivities } = await import('../lib/workflow-executor')\n\n // Cast ctx to AwilixContainer for the resume function\n const container = ctx as unknown as AwilixContainer\n\n try {\n await resumeWorkflowAfterActivities(em, container, workflowInstanceId)\n } catch (error: any) {\n // Ignore error if workflow not ready to resume yet (activities still pending)\n if (!error.message?.includes('Activities still pending')) {\n console.error(\n `[workflows:activity-worker] Failed to resume workflow instance ${workflowInstanceId}:`,\n error.message\n )\n }\n }\n}\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.3-develop.
|
|
3
|
+
"version": "0.6.3-develop.3894.1.352abf4240",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -243,16 +243,16 @@
|
|
|
243
243
|
"zod": "^4.4.3"
|
|
244
244
|
},
|
|
245
245
|
"peerDependencies": {
|
|
246
|
-
"@open-mercato/ai-assistant": "0.6.3-develop.
|
|
247
|
-
"@open-mercato/shared": "0.6.3-develop.
|
|
248
|
-
"@open-mercato/ui": "0.6.3-develop.
|
|
246
|
+
"@open-mercato/ai-assistant": "0.6.3-develop.3894.1.352abf4240",
|
|
247
|
+
"@open-mercato/shared": "0.6.3-develop.3894.1.352abf4240",
|
|
248
|
+
"@open-mercato/ui": "0.6.3-develop.3894.1.352abf4240",
|
|
249
249
|
"react": "^19.0.0",
|
|
250
250
|
"react-dom": "^19.0.0"
|
|
251
251
|
},
|
|
252
252
|
"devDependencies": {
|
|
253
|
-
"@open-mercato/ai-assistant": "0.6.3-develop.
|
|
254
|
-
"@open-mercato/shared": "0.6.3-develop.
|
|
255
|
-
"@open-mercato/ui": "0.6.3-develop.
|
|
253
|
+
"@open-mercato/ai-assistant": "0.6.3-develop.3894.1.352abf4240",
|
|
254
|
+
"@open-mercato/shared": "0.6.3-develop.3894.1.352abf4240",
|
|
255
|
+
"@open-mercato/ui": "0.6.3-develop.3894.1.352abf4240",
|
|
256
256
|
"@testing-library/dom": "^10.4.1",
|
|
257
257
|
"@testing-library/jest-dom": "^6.9.1",
|
|
258
258
|
"@testing-library/react": "^16.3.1",
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
AttachmentPartition,
|
|
8
8
|
} from "@open-mercato/core/modules/attachments/data/entities";
|
|
9
9
|
import type { EntityManager } from "@mikro-orm/postgresql";
|
|
10
|
-
import { checkAttachmentAccess } from "@open-mercato/core/modules/attachments/lib/access";
|
|
10
|
+
import { checkAttachmentAccess, isSuperAdminAuth } from "@open-mercato/core/modules/attachments/lib/access";
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import { attachmentsTag, attachmentErrorSchema } from "../../openapi";
|
|
13
13
|
import {
|
|
@@ -38,7 +38,12 @@ export async function GET(
|
|
|
38
38
|
(resolve("storageDriverFactory") as StorageDriverFactory | null) ??
|
|
39
39
|
new StorageDriverFactory(em);
|
|
40
40
|
|
|
41
|
-
const
|
|
41
|
+
const findFilter: Record<string, unknown> = { id };
|
|
42
|
+
if (auth && !isSuperAdminAuth(auth)) {
|
|
43
|
+
if (auth.tenantId) findFilter.tenantId = auth.tenantId;
|
|
44
|
+
if (auth.orgId) findFilter.organizationId = auth.orgId;
|
|
45
|
+
}
|
|
46
|
+
const attachment = await em.findOne(Attachment, findFilter);
|
|
42
47
|
if (!attachment) {
|
|
43
48
|
return NextResponse.json(
|
|
44
49
|
{ error: "Attachment not found" },
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
writeThumbnailCache,
|
|
12
12
|
} from '@open-mercato/core/modules/attachments/lib/thumbnailCache'
|
|
13
13
|
import { canRenderInlineAttachment } from '@open-mercato/core/modules/attachments/lib/security'
|
|
14
|
-
import { checkAttachmentAccess } from '@open-mercato/core/modules/attachments/lib/access'
|
|
14
|
+
import { checkAttachmentAccess, isSuperAdminAuth } from '@open-mercato/core/modules/attachments/lib/access'
|
|
15
15
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
16
16
|
import { attachmentsTag, imageQuerySchema, attachmentErrorSchema } from '../../../openapi'
|
|
17
17
|
import {
|
|
@@ -53,9 +53,12 @@ export async function GET(
|
|
|
53
53
|
const storageDriverFactory =
|
|
54
54
|
(resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)
|
|
55
55
|
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
const findFilter: Record<string, unknown> = { id }
|
|
57
|
+
if (auth && !isSuperAdminAuth(auth)) {
|
|
58
|
+
if (auth.tenantId) findFilter.tenantId = auth.tenantId
|
|
59
|
+
if (auth.orgId) findFilter.organizationId = auth.orgId
|
|
60
|
+
}
|
|
61
|
+
const attachment = await em.findOne(Attachment, findFilter)
|
|
59
62
|
if (!attachment) {
|
|
60
63
|
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
|
61
64
|
}
|
|
@@ -25,24 +25,191 @@ const NON_CORE_RETENTION_HOURS = toPositiveNumber(process.env.AUDIT_LOGS_NON_COR
|
|
|
25
25
|
const CORE_RETENTION_MS = CORE_RETENTION_DAYS * 24 * 60 * 60 * 1000
|
|
26
26
|
const NON_CORE_RETENTION_MS = NON_CORE_RETENTION_HOURS * 60 * 60 * 1000
|
|
27
27
|
const UUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/
|
|
28
|
+
// Postgres has a hard limit of 65k bind parameters per statement. Each access
|
|
29
|
+
// log row uses 10 bind values (see INSERT below), so 500 rows × 10 = 5 000
|
|
30
|
+
// parameters — well below the limit while keeping memory bounded.
|
|
31
|
+
const MAX_BATCH_ROWS = 500
|
|
28
32
|
|
|
29
33
|
let validationWarningLogged = false
|
|
30
34
|
let runtimeValidationAvailable: boolean | null = null
|
|
31
35
|
|
|
36
|
+
// Module-level registry of in-flight access-log writes. Both `log` and
|
|
37
|
+
// `logMany` opt every promise they kick off into this set so that
|
|
38
|
+
// `flushAccessLog()` can drain them. This is what makes the new
|
|
39
|
+
// fire-and-forget CRUD path safe for test code that asserts on `access_logs`
|
|
40
|
+
// rows immediately after a response — the integration harness defaults to
|
|
41
|
+
// blocking via `OM_CRUD_ACCESS_LOG_BLOCKING=1`, and direct callers can opt
|
|
42
|
+
// in to draining explicitly via `flushAccessLog()`.
|
|
43
|
+
const pendingAccessLogWrites = new Set<Promise<unknown>>()
|
|
44
|
+
|
|
45
|
+
function trackPendingAccessLogWrite<T>(promise: Promise<T>): Promise<T> {
|
|
46
|
+
pendingAccessLogWrites.add(promise as unknown as Promise<unknown>)
|
|
47
|
+
promise
|
|
48
|
+
.catch(() => undefined)
|
|
49
|
+
.finally(() => {
|
|
50
|
+
pendingAccessLogWrites.delete(promise as unknown as Promise<unknown>)
|
|
51
|
+
})
|
|
52
|
+
return promise
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function flushAccessLog(): Promise<void> {
|
|
56
|
+
while (pendingAccessLogWrites.size > 0) {
|
|
57
|
+
const snapshot = Array.from(pendingAccessLogWrites)
|
|
58
|
+
await Promise.allSettled(snapshot)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
const isZodRuntimeMissing = (err: unknown) => err instanceof TypeError && typeof err.message === 'string' && err.message.includes('_zod')
|
|
33
63
|
|
|
64
|
+
type RawEncryptedFields = {
|
|
65
|
+
resourceKind?: unknown
|
|
66
|
+
resourceId?: unknown
|
|
67
|
+
accessType?: unknown
|
|
68
|
+
fieldsJson?: unknown
|
|
69
|
+
contextJson?: unknown
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function serializeJsonColumn(value: unknown): string | null {
|
|
73
|
+
if (value === null || value === undefined) return null
|
|
74
|
+
if (typeof value === 'string') {
|
|
75
|
+
try {
|
|
76
|
+
JSON.parse(value)
|
|
77
|
+
return value
|
|
78
|
+
} catch {
|
|
79
|
+
return JSON.stringify(value)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return JSON.stringify(value)
|
|
84
|
+
} catch {
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
34
89
|
export class AccessLogService {
|
|
35
90
|
constructor(private readonly em: EntityManager) {}
|
|
36
91
|
|
|
37
92
|
async log(input: AccessLogCreateInput): Promise<AccessLog | null> {
|
|
38
|
-
|
|
93
|
+
const promise = this.logInternal(input)
|
|
94
|
+
return trackPendingAccessLogWrite(promise)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async logMany(inputs: AccessLogCreateInput[]): Promise<number> {
|
|
98
|
+
if (!Array.isArray(inputs) || inputs.length === 0) return 0
|
|
99
|
+
const promise = this.logManyInternal(inputs)
|
|
100
|
+
return trackPendingAccessLogWrite(promise)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
flush(): Promise<void> {
|
|
104
|
+
return flushAccessLog()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async logManyInternal(inputs: AccessLogCreateInput[]): Promise<number> {
|
|
108
|
+
// Parsing in parallel matches the legacy fan-out `Promise.all(map(...service.log()))`
|
|
109
|
+
// path's wall-clock; the previous sequential loop made batched writes slower than
|
|
110
|
+
// un-batched on tenants with encryption enabled and pushed UI integration tests
|
|
111
|
+
// over their dialog-stability budget.
|
|
112
|
+
const parsedResults = await Promise.all(inputs.map((input) => this.parseInput(input)))
|
|
113
|
+
const normalized: AccessLogCreateInput[] = []
|
|
114
|
+
for (const parsed of parsedResults) {
|
|
115
|
+
if (parsed) normalized.push(parsed)
|
|
116
|
+
}
|
|
117
|
+
if (!normalized.length) return 0
|
|
118
|
+
|
|
119
|
+
let written = 0
|
|
120
|
+
for (let offset = 0; offset < normalized.length; offset += MAX_BATCH_ROWS) {
|
|
121
|
+
const chunk = normalized.slice(offset, offset + MAX_BATCH_ROWS)
|
|
122
|
+
written += await this.writeChunk(chunk)
|
|
123
|
+
}
|
|
124
|
+
if (written > 0) {
|
|
125
|
+
const fork = this.em.fork({ useContext: true })
|
|
126
|
+
await this.rotate(fork)
|
|
127
|
+
}
|
|
128
|
+
return written
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async writeChunk(chunk: AccessLogCreateInput[]): Promise<number> {
|
|
132
|
+
if (!chunk.length) return 0
|
|
133
|
+
const fork = this.em.fork({ useContext: true })
|
|
134
|
+
const encryption = resolveTenantEncryptionService(fork as any)
|
|
135
|
+
const createdAt = new Date()
|
|
136
|
+
|
|
137
|
+
// Encrypt every row in parallel so encryption-enabled tenants do not pay
|
|
138
|
+
// the N-rows × per-row latency penalty that the previous sequential
|
|
139
|
+
// for-of loop introduced. The legacy `service.log()` fan-out resolved
|
|
140
|
+
// its 50 encryption calls concurrently via `Promise.all`; preserve that
|
|
141
|
+
// characteristic here so the batched single-INSERT path is strictly
|
|
142
|
+
// faster than the legacy parallel-INSERTs path.
|
|
143
|
+
type PreparedRow = {
|
|
144
|
+
tenantId: string | null
|
|
145
|
+
organizationId: string | null
|
|
146
|
+
data: AccessLogCreateInput
|
|
147
|
+
fields: unknown[] | null
|
|
148
|
+
context: Record<string, unknown> | null
|
|
149
|
+
encrypted: RawEncryptedFields | null
|
|
150
|
+
}
|
|
151
|
+
const prepared: PreparedRow[] = await Promise.all(
|
|
152
|
+
chunk.map(async (data) => {
|
|
153
|
+
const fields = Array.isArray(data.fields) && data.fields.length ? data.fields : null
|
|
154
|
+
const context = data.context && Object.keys(data.context).length ? data.context : null
|
|
155
|
+
const tenantId = data.tenantId ?? null
|
|
156
|
+
const organizationId = data.organizationId ?? null
|
|
157
|
+
const encrypted = encryption
|
|
158
|
+
? ((await encryption.encryptEntityPayload(
|
|
159
|
+
E.audit_logs.access_log,
|
|
160
|
+
{
|
|
161
|
+
resourceKind: data.resourceKind,
|
|
162
|
+
resourceId: data.resourceId,
|
|
163
|
+
accessType: data.accessType,
|
|
164
|
+
fieldsJson: fields,
|
|
165
|
+
contextJson: context,
|
|
166
|
+
},
|
|
167
|
+
tenantId,
|
|
168
|
+
organizationId,
|
|
169
|
+
)) as RawEncryptedFields)
|
|
170
|
+
: null
|
|
171
|
+
return { tenantId, organizationId, data, fields, context, encrypted }
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const placeholders: string[] = []
|
|
176
|
+
const params: unknown[] = []
|
|
177
|
+
for (const row of prepared) {
|
|
178
|
+
const { tenantId, organizationId, data, fields, context, encrypted } = row
|
|
179
|
+
const resourceKindOut = encrypted?.resourceKind ?? data.resourceKind
|
|
180
|
+
const resourceIdOut = encrypted?.resourceId ?? data.resourceId
|
|
181
|
+
const accessTypeOut = encrypted?.accessType ?? data.accessType
|
|
182
|
+
const fieldsOut = encrypted?.fieldsJson ?? fields
|
|
183
|
+
const contextOut = encrypted?.contextJson ?? context
|
|
184
|
+
placeholders.push('(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
|
185
|
+
params.push(
|
|
186
|
+
tenantId,
|
|
187
|
+
organizationId,
|
|
188
|
+
data.actorUserId ?? null,
|
|
189
|
+
resourceKindOut,
|
|
190
|
+
resourceIdOut,
|
|
191
|
+
accessTypeOut,
|
|
192
|
+
serializeJsonColumn(fieldsOut),
|
|
193
|
+
serializeJsonColumn(contextOut),
|
|
194
|
+
createdAt,
|
|
195
|
+
null,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
if (!placeholders.length) return 0
|
|
199
|
+
const sql = `insert into "access_logs" ("tenant_id", "organization_id", "actor_user_id", "resource_kind", "resource_id", "access_type", "fields_json", "context_json", "created_at", "deleted_at") values ${placeholders.join(', ')}`
|
|
200
|
+
await fork.getConnection().execute(sql, params)
|
|
201
|
+
return chunk.length
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private async parseInput(input: AccessLogCreateInput): Promise<AccessLogCreateInput | null> {
|
|
39
205
|
const schema = accessLogCreateSchema as typeof accessLogCreateSchema & { _zod?: unknown }
|
|
40
206
|
const canValidate = Boolean(schema && typeof schema.parse === 'function')
|
|
41
207
|
const shouldValidate = canValidate && runtimeValidationAvailable !== false
|
|
42
208
|
if (shouldValidate) {
|
|
43
209
|
try {
|
|
44
|
-
data = schema.parse(input)
|
|
210
|
+
const data = schema.parse(input)
|
|
45
211
|
runtimeValidationAvailable = true
|
|
212
|
+
return data
|
|
46
213
|
} catch (err) {
|
|
47
214
|
if (!isZodRuntimeMissing(err) && !validationWarningLogged) {
|
|
48
215
|
validationWarningLogged = true
|
|
@@ -50,11 +217,15 @@ export class AccessLogService {
|
|
|
50
217
|
console.warn('[audit_logs] falling back to permissive access log payload parser', err)
|
|
51
218
|
}
|
|
52
219
|
if (isZodRuntimeMissing(err)) runtimeValidationAvailable = false
|
|
53
|
-
|
|
220
|
+
return this.normalizeInput(input)
|
|
54
221
|
}
|
|
55
|
-
} else {
|
|
56
|
-
data = this.normalizeInput(input)
|
|
57
222
|
}
|
|
223
|
+
return this.normalizeInput(input)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async logInternal(input: AccessLogCreateInput): Promise<AccessLog | null> {
|
|
227
|
+
const data = await this.parseInput(input)
|
|
228
|
+
if (!data) return null
|
|
58
229
|
const fork = this.em.fork({ useContext: true })
|
|
59
230
|
const fields = Array.isArray(data.fields) && data.fields.length ? data.fields : null
|
|
60
231
|
const context = data.context && Object.keys(data.context).length ? data.context : null
|
|
@@ -62,13 +233,6 @@ export class AccessLogService {
|
|
|
62
233
|
const tenantId = data.tenantId ?? null
|
|
63
234
|
const organizationId = data.organizationId ?? null
|
|
64
235
|
|
|
65
|
-
type AccessLogEncryptedFields = {
|
|
66
|
-
resourceKind?: unknown
|
|
67
|
-
resourceId?: unknown
|
|
68
|
-
accessType?: unknown
|
|
69
|
-
fieldsJson?: unknown
|
|
70
|
-
contextJson?: unknown
|
|
71
|
-
}
|
|
72
236
|
const encryption = resolveTenantEncryptionService(fork as any)
|
|
73
237
|
const encrypted = encryption
|
|
74
238
|
? ((await encryption.encryptEntityPayload(
|
|
@@ -82,7 +246,7 @@ export class AccessLogService {
|
|
|
82
246
|
},
|
|
83
247
|
tenantId,
|
|
84
248
|
organizationId,
|
|
85
|
-
)) as
|
|
249
|
+
)) as RawEncryptedFields)
|
|
86
250
|
: null
|
|
87
251
|
|
|
88
252
|
const payload = {
|
|
@@ -102,8 +266,8 @@ export class AccessLogService {
|
|
|
102
266
|
payload.resourceKind,
|
|
103
267
|
payload.resourceId,
|
|
104
268
|
payload.accessType,
|
|
105
|
-
|
|
106
|
-
|
|
269
|
+
serializeJsonColumn(payload.fieldsJson),
|
|
270
|
+
serializeJsonColumn(payload.contextJson),
|
|
107
271
|
createdAt,
|
|
108
272
|
null,
|
|
109
273
|
],
|
|
@@ -55,7 +55,7 @@ export default function AuthProfilePage() {
|
|
|
55
55
|
setError(null)
|
|
56
56
|
try {
|
|
57
57
|
const { ok, result } = await apiCall<ProfileResponse>('/api/auth/profile')
|
|
58
|
-
if (!ok) throw new Error('
|
|
58
|
+
if (!ok) throw new Error(t('auth.profile.form.errors.load', 'Failed to load profile.'))
|
|
59
59
|
const resolvedEmail = typeof result?.email === 'string' ? result.email : ''
|
|
60
60
|
if (!cancelled) setEmail(resolvedEmail)
|
|
61
61
|
} catch (err) {
|
|
@@ -54,7 +54,7 @@ export default function ProfileChangePasswordPage() {
|
|
|
54
54
|
setError(null)
|
|
55
55
|
try {
|
|
56
56
|
const { ok, result } = await apiCall<ProfileResponse>('/api/auth/profile')
|
|
57
|
-
if (!ok) throw new Error('
|
|
57
|
+
if (!ok) throw new Error(t('auth.profile.form.errors.load', 'Failed to load profile.'))
|
|
58
58
|
const resolvedEmail = typeof result?.email === 'string' ? result.email : ''
|
|
59
59
|
if (!cancelled) setEmail(resolvedEmail)
|
|
60
60
|
} catch (err) {
|
|
@@ -176,7 +176,7 @@ export default function EditUserPage({ params }: { params?: { id?: string } }) {
|
|
|
176
176
|
const { ok, result } = await apiCall<UserListResponse>(
|
|
177
177
|
`/api/auth/users?id=${encodeURIComponent(String(id))}&page=1&pageSize=1`,
|
|
178
178
|
)
|
|
179
|
-
if (!ok) throw new Error('
|
|
179
|
+
if (!ok) throw new Error(tRef.current('auth.users.form.errors.load', 'Failed to load user data'))
|
|
180
180
|
const item = Array.isArray(result?.items) ? result?.items?.[0] : undefined
|
|
181
181
|
if (!cancelled) {
|
|
182
182
|
setActorIsSuperAdmin(Boolean(result?.isSuperAdmin))
|
|
@@ -109,7 +109,12 @@ export default function CreateUserPage() {
|
|
|
109
109
|
setWidgetError(null)
|
|
110
110
|
try {
|
|
111
111
|
const { ok, result } = await apiCall<WidgetCatalogResponse>('/api/dashboards/widgets/catalog')
|
|
112
|
-
if (!ok)
|
|
112
|
+
if (!ok) {
|
|
113
|
+
throw new Error(t(
|
|
114
|
+
'auth.users.widgets.errors.load',
|
|
115
|
+
'Unable to load dashboard widgets. You can configure them later from the user page.',
|
|
116
|
+
))
|
|
117
|
+
}
|
|
113
118
|
if (!cancelled) {
|
|
114
119
|
const rawItems: unknown[] = Array.isArray(result?.items) ? result?.items ?? [] : []
|
|
115
120
|
const normalized = rawItems
|
package/src/modules/auth/di.ts
CHANGED
|
@@ -1,11 +1,34 @@
|
|
|
1
|
-
import { asClass } from 'awilix'
|
|
1
|
+
import { asClass, asFunction } from 'awilix'
|
|
2
2
|
import type { AppContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import type { CacheStrategy } from '@open-mercato/cache'
|
|
3
5
|
import { AuthService } from '@open-mercato/core/modules/auth/services/authService'
|
|
4
6
|
import { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
7
|
+
import {
|
|
8
|
+
createRbacFallbackCache,
|
|
9
|
+
isRbacDefaultCacheEnabled,
|
|
10
|
+
resetRbacFallbackCache,
|
|
11
|
+
} from '@open-mercato/core/modules/auth/services/rbacDefaultCache'
|
|
12
|
+
|
|
13
|
+
export { resetRbacFallbackCache }
|
|
5
14
|
|
|
6
15
|
export function register(container: AppContainer) {
|
|
7
16
|
// Register or override core auth service
|
|
8
17
|
container.register({ authService: asClass(AuthService).scoped() })
|
|
9
|
-
// RBAC service
|
|
10
|
-
|
|
18
|
+
// RBAC service. The bare `asClass(...).scoped()` registration matches
|
|
19
|
+
// develop and is the default. Setting `OM_RBAC_DEFAULT_CACHE=on` opts
|
|
20
|
+
// into the in-process LRU fallback for deployments that don't wire a
|
|
21
|
+
// shared CacheStrategy (CLI scripts, lean test bootstraps). Production
|
|
22
|
+
// bootstraps that already register `cache` via `@open-mercato/cache`
|
|
23
|
+
// preempt this fallback because the container's existing `cache`
|
|
24
|
+
// registration wins when `RbacService` reaches for it.
|
|
25
|
+
if (isRbacDefaultCacheEnabled()) {
|
|
26
|
+
container.register({
|
|
27
|
+
rbacService: asFunction((cradle: { em: EntityManager; cache?: CacheStrategy }) => {
|
|
28
|
+
return new RbacService(cradle.em, cradle.cache ?? createRbacFallbackCache())
|
|
29
|
+
}).scoped(),
|
|
30
|
+
})
|
|
31
|
+
} else {
|
|
32
|
+
container.register({ rbacService: asClass(RbacService).scoped() })
|
|
33
|
+
}
|
|
11
34
|
}
|