From 0f3d235d26160f08fbb22e2863213e6feeaf8e21 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Tue, 10 Feb 2026 19:12:03 -0400 Subject: [PATCH 1/4] =?UTF-8?q?FOUR-29250=20End=20Event=20=E2=80=93=20Exte?= =?UTF-8?q?rnal=20URL=20with=20Mustache/FEEL=20Support=20Description:=20fe?= =?UTF-8?q?at(end=20event):=20support=20Mustache=20in=20external=20URL=20f?= =?UTF-8?q?or=20element=20destination?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve Mustache expressions in end event "External URL" when the token reaches the end event, using the same context as scripts/screens (APP_URL, _request, _user, process variables). - Add getElementDestinationMustacheContext() to build context via DataManager with fallback; normalize to plain array for Mustache. - Add resolveElementDestinationUrl() to decode HTML entities and render URL template with MustacheExpressionEvaluator. FEEL is not supported. - Apply resolution only for externalURL type; conditional redirect URLs also go through Mustache when destination is external URL. - Harden getElementDestinationAttribute(): ensure conditionalRedirectProp and elementDestinationProp are never null (json_decode ?? [], pass ?? []). - Modeler: allow URL validation when string contains {{ (Mustache); update helper and error copy to document _request.id, _user.id, process vars. Related tickets: https://processmaker.atlassian.net/browse/FOUR-29250 --- ProcessMaker/Models/ProcessRequestToken.php | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index 68de753276..e897285003 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -24,6 +24,8 @@ use ProcessMaker\Nayra\Contracts\Bpmn\TokenInterface; use ProcessMaker\Nayra\Managers\WorkflowManagerDefault; use ProcessMaker\Nayra\Storage\BpmnDocument; +use ProcessMaker\Managers\DataManager; +use ProcessMaker\Models\MustacheExpressionEvaluator; use ProcessMaker\Notifications\ActivityActivatedNotification; use ProcessMaker\Notifications\TaskReassignmentNotification; use ProcessMaker\Query\Expression; @@ -1440,6 +1442,49 @@ public function reassign($toUserId, User $requestingUser, $comments = '') } } + /** + * Build context for Mustache (end event external URL). Same as scripts/screens: _user, _request, process data, APP_URL. + */ + private function getElementDestinationMustacheContext(): array + { + try { + $context = (new DataManager())->getData($this); + } catch (Throwable $e) { + $request = $this->processRequest; + $context = array_merge($request->data ?? [], $request ? (new DataManager())->updateRequestMagicVariable([], $request) : []); + $user = $this->user ?? \Illuminate\Support\Facades\Auth::user(); + if ($user) { + $userData = $user->attributesToArray(); + unset($userData['remember_token']); + $context['_user'] = $userData; + } + } + + $context['APP_URL'] = config('app.url'); + + // Normalize to plain arrays/scalars so Mustache resolves all keys (common PHP idiom) + $normalized = json_decode(json_encode($context), true); + + return is_array($normalized) ? $normalized : []; + } + + /** + * Resolve Mustache in end event external URL. FEEL is not supported here; use Mustache only. + * Context: APP_URL, _request, _user, process variables (same as getElementDestinationMustacheContext). + * + * Example (Mustache): + * {{APP_URL}}/admin/users/{{_request.id}}/edit -> https://example.com/admin/users/123/edit + * {{APP_URL}}/webentry/{{_request.id}} -> https://example.com/webentry/123 + * {{APP_URL}}/path/{{my_process_var}} -> uses process variable my_process_var + */ + private function resolveElementDestinationUrl(string $url): string + { + $url = html_entity_decode($url, ENT_QUOTES | ENT_HTML401, 'UTF-8'); + $context = $this->getElementDestinationMustacheContext(); + + return (new MustacheExpressionEvaluator())->render($url, $context); + } + /** * Determines the destination based on the type of element destination property * @@ -1481,6 +1526,9 @@ private function getElementDestination($elementDestinationType, $elementDestinat $elementDestination = $elementDestinationProp['value']['url'] ?? null; } } + if ($elementDestinationType === 'externalURL' && is_string($elementDestination) && $elementDestination !== '') { + $elementDestination = $this->resolveElementDestinationUrl($elementDestination); + } break; case 'taskList': $elementDestination = route('tasks.index'); From 5ad73daf501b2e43ee8318c67394b0b488ce7653 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Wed, 11 Feb 2026 09:13:39 -0400 Subject: [PATCH 2/4] FOUR-29250 Add unit tests for end event external URL Mustache context and resolution. --- ...cessRequestTokenElementDestinationTest.php | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php diff --git a/tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php b/tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php new file mode 100644 index 0000000000..49c01fd873 --- /dev/null +++ b/tests/unit/ProcessMaker/Models/ProcessRequestTokenElementDestinationTest.php @@ -0,0 +1,146 @@ +getMethod($methodName); + $method->setAccessible(true); + + return $method->invokeArgs($object, $args); + } + + /** + * Test getElementDestinationMustacheContext returns context with APP_URL, _request, _user and process data. + */ + public function testGetElementDestinationMustacheContextReturnsExpectedKeys(): void + { + $user = User::factory()->create(['status' => 'ACTIVE']); + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $user->id, + 'data' => ['processVar' => 'value123'], + ]); + + $token = ProcessRequestToken::factory()->create([ + 'process_id' => $process->id, + 'process_request_id' => $request->id, + 'user_id' => $user->id, + 'element_id' => 'end_1', + 'element_type' => 'end_event', + ]); + + $context = $this->invokePrivateMethod($token, 'getElementDestinationMustacheContext'); + + $this->assertIsArray($context); + $this->assertArrayHasKey('APP_URL', $context); + $this->assertSame(config('app.url'), $context['APP_URL']); + $this->assertArrayHasKey('_request', $context); + $this->assertIsArray($context['_request']); + $this->assertArrayHasKey('id', $context['_request']); + $this->assertSame((string) $request->id, (string) $context['_request']['id']); + $this->assertArrayHasKey('case_number', $context['_request']); + $this->assertArrayHasKey('_user', $context); + $this->assertIsArray($context['_user']); + $this->assertArrayHasKey('id', $context['_user']); + $this->assertArrayHasKey('processVar', $context); + $this->assertSame('value123', $context['processVar']); + } + + /** + * Test resolveElementDestinationUrl resolves Mustache placeholders APP_URL and _request.id. + */ + public function testResolveElementDestinationUrlResolvesMustache(): void + { + $user = User::factory()->create(['status' => 'ACTIVE']); + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $user->id, + 'data' => [], + ]); + + $token = ProcessRequestToken::factory()->create([ + 'process_id' => $process->id, + 'process_request_id' => $request->id, + 'user_id' => $user->id, + 'element_id' => 'end_1', + 'element_type' => 'end_event', + ]); + + $urlTemplate = '{{APP_URL}}/path/{{_request.id}}'; + $resolved = $this->invokePrivateMethod($token, 'resolveElementDestinationUrl', [$urlTemplate]); + + $expectedUrl = config('app.url') . '/path/' . $request->id; + $this->assertSame($expectedUrl, $resolved); + } + + /** + * Test resolveElementDestinationUrl resolves process variable in URL. + */ + public function testResolveElementDestinationUrlResolvesProcessVariable(): void + { + $user = User::factory()->create(['status' => 'ACTIVE']); + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $user->id, + 'data' => ['segment' => 'admin'], + ]); + + $token = ProcessRequestToken::factory()->create([ + 'process_id' => $process->id, + 'process_request_id' => $request->id, + 'user_id' => $user->id, + 'element_id' => 'end_1', + 'element_type' => 'end_event', + ]); + + $urlTemplate = '{{APP_URL}}/{{segment}}/users'; + $resolved = $this->invokePrivateMethod($token, 'resolveElementDestinationUrl', [$urlTemplate]); + + $this->assertSame(config('app.url') . '/admin/users', $resolved); + } + + /** + * Test resolveElementDestinationUrl decodes HTML entities in template. + */ + public function testResolveElementDestinationUrlDecodesHtmlEntities(): void + { + $user = User::factory()->create(['status' => 'ACTIVE']); + $process = Process::factory()->create(); + $request = ProcessRequest::factory()->create([ + 'process_id' => $process->id, + 'user_id' => $user->id, + 'data' => [], + ]); + + $token = ProcessRequestToken::factory()->create([ + 'process_id' => $process->id, + 'process_request_id' => $request->id, + 'user_id' => $user->id, + 'element_id' => 'end_1', + 'element_type' => 'end_event', + ]); + + $urlWithEntities = 'https://example.com/{{_request.id}}'; + $resolved = $this->invokePrivateMethod($token, 'resolveElementDestinationUrl', [$urlWithEntities]); + + $this->assertStringContainsString((string) $request->id, $resolved); + $this->assertStringContainsString('https://example.com/', $resolved); + } +} From 517987f742947ceddd0c0a87379f73e90d41a31a Mon Sep 17 00:00:00 2001 From: Roly Rudy Gutierrez Pinto Date: Wed, 11 Feb 2026 15:56:45 -0400 Subject: [PATCH 3/4] Apply suggestion from @devmiguelangel Co-authored-by: Miguel Angel --- ProcessMaker/Models/ProcessRequestToken.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index e897285003..277efcf14b 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -1450,6 +1450,10 @@ private function getElementDestinationMustacheContext(): array try { $context = (new DataManager())->getData($this); } catch (Throwable $e) { + Log::warning('Failed to load Mustache context via DataManager, falling back to request data', [ + 'token_id' => $this->id, + 'error' => $e->getMessage(), + ]); $request = $this->processRequest; $context = array_merge($request->data ?? [], $request ? (new DataManager())->updateRequestMagicVariable([], $request) : []); $user = $this->user ?? \Illuminate\Support\Facades\Auth::user(); From 6af7877849b798605677d4115806cc362d93db9b Mon Sep 17 00:00:00 2001 From: Roly Rudy Gutierrez Pinto Date: Wed, 11 Feb 2026 15:59:45 -0400 Subject: [PATCH 4/4] Apply suggestion from @devmiguelangel Co-authored-by: Miguel Angel --- ProcessMaker/Models/ProcessRequestToken.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ProcessMaker/Models/ProcessRequestToken.php b/ProcessMaker/Models/ProcessRequestToken.php index 277efcf14b..5673d2fbde 100644 --- a/ProcessMaker/Models/ProcessRequestToken.php +++ b/ProcessMaker/Models/ProcessRequestToken.php @@ -1456,7 +1456,8 @@ private function getElementDestinationMustacheContext(): array ]); $request = $this->processRequest; $context = array_merge($request->data ?? [], $request ? (new DataManager())->updateRequestMagicVariable([], $request) : []); - $user = $this->user ?? \Illuminate\Support\Facades\Auth::user(); + $user = $this->user ?? auth()->user(); + if ($user) { $userData = $user->attributesToArray(); unset($userData['remember_token']);