From 494852af5d668f8bc68093164562c7f9b123813d Mon Sep 17 00:00:00 2001 From: tivmof Date: Mon, 10 Aug 2026 08:20:12 +0300 Subject: [PATCH 1/3] feat: Implementing appUrl inside the notify subject/body interpolation --- .../tenant/TenantConfigurationKeyPolicy.java | 5 ++- .../TenantConfigurationKeyPolicyTest.java | 2 + .../intent/generator/NotificationSupport.java | 40 ++++++++++++++++--- .../main/resources/intent-assistant-guide.md | 6 +++ .../generator/NotificationSupportTest.java | 30 ++++++++++++++ .../commons/config/DirigibleConfig.java | 6 +++ 6 files changed, 82 insertions(+), 7 deletions(-) diff --git a/components/core/core-configurations/src/main/java/org/eclipse/dirigible/components/configurations/tenant/TenantConfigurationKeyPolicy.java b/components/core/core-configurations/src/main/java/org/eclipse/dirigible/components/configurations/tenant/TenantConfigurationKeyPolicy.java index 283e3c0addd..7c3351ede32 100644 --- a/components/core/core-configurations/src/main/java/org/eclipse/dirigible/components/configurations/tenant/TenantConfigurationKeyPolicy.java +++ b/components/core/core-configurations/src/main/java/org/eclipse/dirigible/components/configurations/tenant/TenantConfigurationKeyPolicy.java @@ -52,7 +52,10 @@ class TenantConfigurationKeyPolicy { // Whether the per-path CMS access grants are enforced at all. Per-tenant because whether // a tenant restricts folders by role is its own decision; the resolver reads it per // request, so switching it applies immediately. - "DIRIGIBLE_CMS_ROLES_ENABLED"); + "DIRIGIBLE_CMS_ROLES_ENABLED", // + // The application's externally-reachable base URL (e.g. the {appUrl} notify token) - + // per-tenant because a tenant may be served from its own subdomain/host. + "DIRIGIBLE_APP_BASE_URL"); /** * The full list of configuration keys a tenant may override, in display order. diff --git a/components/core/core-configurations/src/test/java/org/eclipse/dirigible/components/configurations/tenant/TenantConfigurationKeyPolicyTest.java b/components/core/core-configurations/src/test/java/org/eclipse/dirigible/components/configurations/tenant/TenantConfigurationKeyPolicyTest.java index 185ca3a975c..337aad19c55 100644 --- a/components/core/core-configurations/src/test/java/org/eclipse/dirigible/components/configurations/tenant/TenantConfigurationKeyPolicyTest.java +++ b/components/core/core-configurations/src/test/java/org/eclipse/dirigible/components/configurations/tenant/TenantConfigurationKeyPolicyTest.java @@ -35,6 +35,8 @@ void brandingKeysAreInjectable() { assertTrue(policy.isInjectable("DIRIGIBLE_APPLICATION_LANGUAGES")); assertTrue(policy.isInjectable("DIRIGIBLE_DOCUMENTS_EXT_CONTENT_TYPE_MS_ENABLED")); assertTrue(policy.isInjectable("DIRIGIBLE_CMS_ROLES_ENABLED")); + // The app's external base URL (the {appUrl} notify token) is per-tenant overridable. + assertTrue(policy.isInjectable("DIRIGIBLE_APP_BASE_URL")); } @Test diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java index bcb26501802..558704c77b7 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java @@ -17,6 +17,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.eclipse.dirigible.commons.config.DirigibleConfig; import org.eclipse.dirigible.components.intent.model.EntityIntent; import org.eclipse.dirigible.components.intent.model.FieldIntent; import org.eclipse.dirigible.components.intent.model.NotificationIntent; @@ -27,12 +28,13 @@ * {@code @Listener} pastes in. Kept free of Spring/IO so the tricky bits are unit-tested directly. * *

- * A value or {@code {placeholder}} is one of: a literal, a direct field of the event entity - * (rendered {@code entity.}), or a one-hop {@code relation.field} of a to-one - * relation (rendered against a related entity the listener loads once by FK id - the same one-hop - * mechanism the decision resolvers use, see {@link ProcessResolverSupport}). Multi-hop paths are - * not supported. The {@code when} guard supports a single {@code field ==|!= literal} comparison on - * a direct field. + * A value or {@code {placeholder}} is one of: the reserved {@code appUrl} config token (the + * application's external base URL, see {@link #APP_URL_TOKEN}), a direct field of the event + * entity (rendered {@code entity.}), or a one-hop {@code relation.field} of a + * to-one relation (rendered against a related entity the listener loads once by FK id - the same + * one-hop mechanism the decision resolvers use, see {@link ProcessResolverSupport}). Multi-hop + * paths are not supported. The {@code when} guard supports a single {@code field ==|!= literal} + * comparison on a direct field. */ public final class NotificationSupport { @@ -40,6 +42,29 @@ public final class NotificationSupport { private static final Pattern PLACEHOLDER = Pattern.compile("\\{([A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)?)\\}"); private static final Pattern SIMPLE_COMPARISON = Pattern.compile("\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*(==|!=)\\s*(.+?)\\s*"); + /** + * The reserved {@code {appUrl}} placeholder name - a config-backed token, not an entity field or + * relation. Resolves to the application's external base URL ({@link DirigibleConfig#APP_BASE_URL}, + * tenant-overridable), so a body can compose a deep link by hand, e.g. {@code "Review it here: + * {appUrl}/orders/{id}"}. Deliberately supplies only the origin - the concrete per-entity route is + * the template layer's knowledge, not the intent layer's (see the engine-intent guide's + * path-agnostic rule), so the author appends the rest of the path as plain text and other + * placeholders. + *

+ * Known limitation: the tenant override is resolved through {@code Configuration}'s + * thread-scoped map, which today is populated only for HTTP request threads (see + * {@code TenantConfigurationInitFilter}) - a notify block always fires from an async message + * listener, which re-establishes the tenant's identity but not its configuration overrides. + * Until that dispatch-path gap is closed platform-wide, a tenant override of + * {@code DIRIGIBLE_APP_BASE_URL} will not take effect here; the generated expression still falls + * back correctly to the global environment/default value. + */ + static final String APP_URL_TOKEN = "appUrl"; + + /** The Java expression {@link #APP_URL_TOKEN} resolves to - see its own javadoc for the caveat. */ + private static final String APP_URL_EXPRESSION = + "org.eclipse.dirigible.sdk.core.Configurations.get(\"" + DirigibleConfig.APP_BASE_URL.getKey() + "\", \"\")"; + private NotificationSupport() {} /** @@ -283,6 +308,9 @@ String text(String raw) { * relation load when needed. Returns {@code null} for an unresolvable relation.field. */ private String access(String path) { + if (APP_URL_TOKEN.equals(path)) { + return APP_URL_EXPRESSION; + } int dot = path.indexOf('.'); if (dot < 0) { return "entity." + IntentNaming.pascalCase(path); diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index 001056a17be..3657445df8a 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -64,6 +64,12 @@ Treat it as the contract: anything you propose must parse and validate against i `to` / `subject` / `body` (+ `channel: email`), with `{field}` / `{relation.field}` interpolation in the subject and body, plus the optional **`attach: print`** that mails the record's own rendered document - see *send a document by e-mail*. +- **`{appUrl}` is a reserved config token, not a field.** It resolves to the application's external + base URL (`DIRIGIBLE_APP_BASE_URL`, tenant-overridable), so a body can compose a deep link by hand, + e.g. `body: "Review it here: {appUrl}/orders/{id}"`. It supplies only the origin - the intent layer + does not know the generated app's routes, so the rest of the path is plain text plus other + placeholders, same as any other literal. Because `appUrl` is reserved, an entity must not declare a + field literally named `appUrl` (it would be shadowed in every notify block). - **A recipient that cannot be resolved is surfaced, not silent.** If `to` names a field/relation that does not exist, that notification or schedule is dropped and reported in the generate response's `warnings` (as well as the server log) - fix the reference so the glue is emitted. diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/NotificationSupportTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/NotificationSupportTest.java index b16e641292d..d7dbb9de5df 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/NotificationSupportTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/NotificationSupportTest.java @@ -86,6 +86,36 @@ void directFieldsAndLiterals() { assertEquals("\"Order \" + entity.Id + \" total \" + entity.Total", plan.subjectExpression()); } + @Test + void appUrlResolvesToTheConfigBackedTokenNotAnEntityField() { + Map byName = libraryModel(); + NotificationSupport.Plan plan = + NotificationSupport.plan(notification("ops@x.com", "Order {id} total {total}. Review it here: {appUrl}/orders/{id}"), + byName.get("Order"), byName, Map.of()); + + assertTrue(plan.loads() + .isEmpty(), + "appUrl is a config token, not a relation - it must not register a relation load"); + assertEquals("\"Order \" + entity.Id + \" total \" + entity.Total + \". Review it here: \"" + + " + org.eclipse.dirigible.sdk.core.Configurations.get(\"DIRIGIBLE_APP_BASE_URL\", \"\")" + " + \"/orders/\"" + + " + entity.Id", plan.subjectExpression()); + } + + @Test + void appUrlWorksAlongsideARelationFieldInTheSameBody() { + Map byName = libraryModel(); + NotificationSupport.Plan plan = + NotificationSupport.plan(notification("customer.email", "Hi {customer.name}, review it here: {appUrl}/orders/{id}"), + byName.get("Order"), byName, Map.of()); + + assertEquals(1, plan.loads() + .size(), + "the relation.field placeholder still registers its own load"); + assertEquals("\"Hi \" + (customer == null ? null : customer.Name) + \", review it here: \"" + + " + org.eclipse.dirigible.sdk.core.Configurations.get(\"DIRIGIBLE_APP_BASE_URL\", \"\")" + " + \"/orders/\"" + + " + entity.Id", plan.subjectExpression()); + } + @Test void oneHopRelationFieldLoadsTheRelatedEntity() { Map byName = libraryModel(); diff --git a/modules/commons/commons-config/src/main/java/org/eclipse/dirigible/commons/config/DirigibleConfig.java b/modules/commons/commons-config/src/main/java/org/eclipse/dirigible/commons/config/DirigibleConfig.java index b33d693e206..a6befdc67ed 100644 --- a/modules/commons/commons-config/src/main/java/org/eclipse/dirigible/commons/config/DirigibleConfig.java +++ b/modules/commons/commons-config/src/main/java/org/eclipse/dirigible/commons/config/DirigibleConfig.java @@ -70,6 +70,12 @@ public enum DirigibleConfig { HOME_URL("DIRIGIBLE_HOME_URL", "services/web/home/"), // + /** + * The application's externally-reachable base URL, used to build absolute links (e.g. in + * notification emails). + */ + APP_BASE_URL("DIRIGIBLE_APP_BASE_URL", ""), // + APPLICATION_LANGUAGES("DIRIGIBLE_APPLICATION_LANGUAGES", "en"), // MAIL_USERNAME("DIRIGIBLE_MAIL_USERNAME", null), // From 95098feb6f4787af11739c68684b41bd1c004733 Mon Sep 17 00:00:00 2001 From: tivmof Date: Mon, 10 Aug 2026 13:33:31 +0300 Subject: [PATCH 2/3] feat: Implementing tenant config overrides into client-Java listener dispatch --- .../intent/generator/NotificationSupport.java | 14 +- components/engine/engine-java/pom.xml | 11 ++ .../java/listener/ListenerClassConsumer.java | 20 ++- .../listener/ListenerClassConsumerTest.java | 140 ++++++++++++++++++ 4 files changed, 175 insertions(+), 10 deletions(-) create mode 100644 components/engine/engine-java/src/test/java/org/eclipse/dirigible/engine/java/listener/ListenerClassConsumerTest.java diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java index 558704c77b7..f42dfbb713e 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java @@ -51,17 +51,15 @@ public final class NotificationSupport { * path-agnostic rule), so the author appends the rest of the path as plain text and other * placeholders. *

- * Known limitation: the tenant override is resolved through {@code Configuration}'s - * thread-scoped map, which today is populated only for HTTP request threads (see - * {@code TenantConfigurationInitFilter}) - a notify block always fires from an async message - * listener, which re-establishes the tenant's identity but not its configuration overrides. - * Until that dispatch-path gap is closed platform-wide, a tenant override of - * {@code DIRIGIBLE_APP_BASE_URL} will not take effect here; the generated expression still falls - * back correctly to the global environment/default value. + * Resolved per dispatch inside the sending tenant's own configuration scope - the generated + * listener (via {@code ListenerClassConsumer}) loads that tenant's overrides before invoking the + * handler, the same way {@code TenantConfigurationInitFilter} does for HTTP requests, so a tenant + * override of {@code DIRIGIBLE_APP_BASE_URL} takes effect here, falling back to the global + * environment/default value when the tenant has not set one. */ static final String APP_URL_TOKEN = "appUrl"; - /** The Java expression {@link #APP_URL_TOKEN} resolves to - see its own javadoc for the caveat. */ + /** The Java expression {@link #APP_URL_TOKEN} resolves to. */ private static final String APP_URL_EXPRESSION = "org.eclipse.dirigible.sdk.core.Configurations.get(\"" + DirigibleConfig.APP_BASE_URL.getKey() + "\", \"\")"; diff --git a/components/engine/engine-java/pom.xml b/components/engine/engine-java/pom.xml index 2b7c22ea1c3..f85603c5b26 100644 --- a/components/engine/engine-java/pom.xml +++ b/components/engine/engine-java/pom.xml @@ -102,6 +102,17 @@ ${project.version} + + + org.eclipse.dirigible + dirigible-components-core-configurations + ${project.version} + +