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'); 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); + } +}