diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template index 3acd5ea8880..97adce0a0ef 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Notification.java.template @@ -54,99 +54,106 @@ public class ${className}Notification implements MessageHandler { if (!(${guardExpression})) { return; } + // The try opens HERE, before the relation loads and the attachment render (#7290): those read + // the database too - a connection blip, a foreign key at a row a concurrent delete removed, a + // broken .print template - and a throw from any of them used to propagate straight out of + // onMessage with no stamp: the broker redelivers forever and the record's outcome stays empty, + // exactly the silent state #7023 introduced the stamp to remove. Mirrors the notify branch of + // Job.java.template (#7278) and Transition.java.template/Send.java.template, which already open + // their try before the loads. + try { #foreach($load in $relationLoads) - ${load.targetEntity}Entity ${load.local} = entity.${load.fkProperty} == null ? null : new ${load.targetEntity}Repository().findById(entity.${load.fkProperty}); + ${load.targetEntity}Entity ${load.local} = entity.${load.fkProperty} == null ? null : new ${load.targetEntity}Repository().findById(entity.${load.fkProperty}); #end #if($usesRecordUrl == "true") - // {recordUrl} - the deep link to the record this message is about. The ROUTE is composed HERE, - // in the layer that knows the generated application's URL layout; the intent layer contributes - // only the entity and its key, which is what keeps it path-agnostic. - String recordUrl = Configurations.get("DIRIGIBLE_APP_BASE_URL", "") - + "/services/web/${projectName}/gen/${genFolderName}/index.html#/${recordUrlEntity}/" + entity.${recordUrlKeyProperty} - + "/edit"; + // {recordUrl} - the deep link to the record this message is about. The ROUTE is composed HERE, + // in the layer that knows the generated application's URL layout; the intent layer contributes + // only the entity and its key, which is what keeps it path-agnostic. + String recordUrl = Configurations.get("DIRIGIBLE_APP_BASE_URL", "") + + "/services/web/${projectName}/gen/${genFolderName}/index.html#/${recordUrlEntity}/" + entity.${recordUrlKeyProperty} + + "/edit"; #end #if($usesInboxUrl == "true") - // {inboxUrl} - the deep link to the recipient's process Inbox. - String inboxUrl = Configurations.get("DIRIGIBLE_APP_BASE_URL", "") - + "/services/web/${projectName}/gen/${genFolderName}/index.html#/inbox"; + // {inboxUrl} - the deep link to the recipient's process Inbox. + String inboxUrl = Configurations.get("DIRIGIBLE_APP_BASE_URL", "") + + "/services/web/${projectName}/gen/${genFolderName}/index.html#/inbox"; #end - String to = ${toExpression}; - if (to == null || to.isBlank()) { - return; - } - String subject = ${subjectExpression}; - String body = ${bodyExpression}; - Map part = new HashMap(); - part.put("type", "text"); - part.put("contentType", "text/plain"); - part.put("text", body); - List parts = new java.util.ArrayList<>(); - parts.add(part); + String to = ${toExpression}; + if (to == null || to.isBlank()) { + return; + } + String subject = ${subjectExpression}; + String body = ${bodyExpression}; + Map part = new HashMap(); + part.put("type", "text"); + part.put("contentType", "text/plain"); + part.put("text", body); + List parts = new java.util.ArrayList<>(); + parts.add(part); #if($attach == "print") - // attach: print - render the record's own print template to PDF server-side (the generated - // feeder assembles the { document, items } payload the template binds) and attach it, so the - // recipient receives the document itself and not just a notice about it. + // attach: print - render the record's own print template to PDF server-side (the generated + // feeder assembles the { document, items } payload the template binds) and attach it, so the + // recipient receives the document itself and not just a notice about it. #if($attachLanguageFkProperty != "") - ${attachLanguageTargetEntity}Entity attachLanguageSource = entity.${attachLanguageFkProperty} == null ? null - : new ${attachLanguageTargetEntity}Repository().findById(entity.${attachLanguageFkProperty}); + ${attachLanguageTargetEntity}Entity attachLanguageSource = entity.${attachLanguageFkProperty} == null ? null + : new ${attachLanguageTargetEntity}Repository().findById(entity.${attachLanguageFkProperty}); #end - String language = ${attachLanguageExpression}; - Map document = new HashMap(); - document.put("type", "attachment"); - document.put("contentType", "application/pdf"); - document.put("fileName", ${attachFileNameExpression}); - // Bind the render language to the thread so the feeder's multilingual overlay resolves nomenclature - // values in the SAME language as the template - there is no request here to carry Accept-Language - // (dirigible #6947). Cleared in the finally so the pooled worker thread never leaks it. - byte[] data; - org.eclipse.dirigible.sdk.security.User.setLanguage(language); - try { - data = org.eclipse.dirigible.sdk.print.Print.render("${attachEntity}", language, - new ${attachEntity}PrintFeeder().feed(entity.${attachKeyProperty})); - } finally { - org.eclipse.dirigible.sdk.security.User.clearLanguage(); - } - document.put("data", data); - parts.add(document); + String language = ${attachLanguageExpression}; + Map document = new HashMap(); + document.put("type", "attachment"); + document.put("contentType", "application/pdf"); + document.put("fileName", ${attachFileNameExpression}); + // Bind the render language to the thread so the feeder's multilingual overlay resolves nomenclature + // values in the SAME language as the template - there is no request here to carry Accept-Language + // (dirigible #6947). Cleared in the finally so the pooled worker thread never leaks it. + byte[] data; + org.eclipse.dirigible.sdk.security.User.setLanguage(language); + try { + data = org.eclipse.dirigible.sdk.print.Print.render("${attachEntity}", language, + new ${attachEntity}PrintFeeder().feed(entity.${attachKeyProperty})); + } finally { + org.eclipse.dirigible.sdk.security.User.clearLanguage(); + } + document.put("data", data); + parts.add(document); #elseif($attach == "report") - // attach: { report, bind } - run the declared report scoped to THIS recipient and attach the - // rendered PDF. The bindings ARE the report's parameters, which is what makes the rows this - // recipient's own: a customer statement is a period of rows, not one record's document. + // attach: { report, bind } - run the declared report scoped to THIS recipient and attach the + // rendered PDF. The bindings ARE the report's parameters, which is what makes the rows this + // recipient's own: a customer statement is a period of rows, not one record's document. #if($attachLanguageFkProperty != "") - ${attachLanguageTargetEntity}Entity attachLanguageSource = entity.${attachLanguageFkProperty} == null ? null - : new ${attachLanguageTargetEntity}Repository().findById(entity.${attachLanguageFkProperty}); + ${attachLanguageTargetEntity}Entity attachLanguageSource = entity.${attachLanguageFkProperty} == null ? null + : new ${attachLanguageTargetEntity}Repository().findById(entity.${attachLanguageFkProperty}); #end - String language = ${attachLanguageExpression}; - Map reportFilter = new HashMap<>(); + String language = ${attachLanguageExpression}; + Map reportFilter = new HashMap<>(); #foreach($binding in $attachReportBindings) - reportFilter.put("${binding.parameter}", reportValue(${binding.expression})); + reportFilter.put("${binding.parameter}", reportValue(${binding.expression})); #end - Map document = new HashMap(); - document.put("type", "attachment"); - document.put("contentType", "application/pdf"); - document.put("fileName", ${attachFileNameExpression}); - // Bind the render language to the thread so the report query's :language overlay resolves the - // translatable dimensions in the SAME language as the template - there is no request here to carry - // Accept-Language (dirigible #6947). Cleared in the finally so the pooled worker thread never leaks it. - byte[] data; - org.eclipse.dirigible.sdk.security.User.setLanguage(language); - try { - Map reportData = new HashMap<>(); - // The bound values double as the rendered header, so the PDF states which slice it is - a - // table of rows never does. - reportData.put("document", reportFilter); - reportData.put("items", new gen.${attachReportGenFolder}.data.${attachReportPerspective}.${attachReport}Repository() - .findAll(null, null, reportFilter)); - data = org.eclipse.dirigible.sdk.print.Print.render("${attachReport}", language, - org.eclipse.dirigible.sdk.utils.Json.stringify(reportData)); - } finally { - org.eclipse.dirigible.sdk.security.User.clearLanguage(); - } - document.put("data", data); - parts.add(document); + Map document = new HashMap(); + document.put("type", "attachment"); + document.put("contentType", "application/pdf"); + document.put("fileName", ${attachFileNameExpression}); + // Bind the render language to the thread so the report query's :language overlay resolves the + // translatable dimensions in the SAME language as the template - there is no request here to carry + // Accept-Language (dirigible #6947). Cleared in the finally so the pooled worker thread never leaks it. + byte[] data; + org.eclipse.dirigible.sdk.security.User.setLanguage(language); + try { + Map reportData = new HashMap<>(); + // The bound values double as the rendered header, so the PDF states which slice it is - a + // table of rows never does. + reportData.put("document", reportFilter); + reportData.put("items", new gen.${attachReportGenFolder}.data.${attachReportPerspective}.${attachReport}Repository() + .findAll(null, null, reportFilter)); + data = org.eclipse.dirigible.sdk.print.Print.render("${attachReport}", language, + org.eclipse.dirigible.sdk.utils.Json.stringify(reportData)); + } finally { + org.eclipse.dirigible.sdk.security.User.clearLanguage(); + } + document.put("data", data); + parts.add(document); #end - String from = Configurations.get("DIRIGIBLE_MAIL_SENDER", "noreply@dirigible.io"); - try { + String from = Configurations.get("DIRIGIBLE_MAIL_SENDER", "noreply@dirigible.io"); Mail.send(from, new String[] {to}, new String[0], new String[0], subject, parts); #if($notifyOutcomeProperty != "") stampNotifyOutcome(entity.${notifyOutcomeKeyProperty}, null); diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index b265aea5d72..6e2d57a1576 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -2450,6 +2450,74 @@ void a_scheduled_notification_keeps_its_row_loads_and_attachment_render_inside_t "the tick's summary still reports the totals"); } + @Test + void an_event_notification_keeps_its_relation_loads_and_attachment_render_inside_the_fail_soft_try() { + // Issue #7290 (the single-record twin of #7233/#7278): the per-row try above covers a + // SCHEDULE's notify branch. A notifications: entry (one record, no loop) had the identical gap + // - only Mail.send sat inside the try, so a relation load or a broken .print template + // propagated straight out of onMessage with NO stamp: the broker redelivers forever and the + // record's outcome stays empty, exactly the silent state #7023 introduced the stamp to remove. + String yaml = """ + name: billing + entities: + - name: Customer + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - { name: email, type: string } + - { name: locale, type: string, length: 5 } + - name: Invoice + function: Document + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: dueDate, type: date } + - { name: reminderOutcome, type: string, length: 128 } + relations: + - { name: Customer, kind: manyToOne, to: Customer } + - name: InvoiceItem + function: DocumentItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: amount, type: decimal } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } + notifications: + - name: invoiceIssued + event: { onCreate: Invoice } + to: Customer.email + subject: "Invoice {id} issued" + body: "Dear {Customer.name}, your invoice is attached." + attach: print + languageFrom: Customer.locale + outcome: reminderOutcome + """; + writeIntent(yaml); + restAssuredExecutor.execute(() -> given().when() + .post(GENERATE_URL) + .then() + .statusCode(200)); + generateFromModel("template-application-events-java/template/template.js", "billing.glue"); + + String notification = codeOf("gen/events/billing/InvoiceIssuedNotification.java"); + int method = notification.indexOf("public void onMessage(String message) {"); + int tryOpens = notification.indexOf("try {", method); + int load = notification.indexOf("new CustomerRepository().findById(entity.Customer)"); + int render = notification.indexOf("Print.render(\"Invoice\","); + int send = notification.indexOf("Mail.send("); + int catches = notification.indexOf("} catch (Exception ex) {"); + assertTrue(method > 0 && tryOpens > 0 && load > 0 && render > 0 && send > 0 && catches > 0, "got: " + notification); + // The ordering is the whole fix: the try must open before the relation load. + assertTrue(tryOpens < load, "the recipient's one-hop relation load runs inside the fail-soft try"); + assertTrue(load < render, "the attachment render follows the load, in the same try"); + assertTrue(render < send && send < catches, "render, then send, then the catch - one try encloses both"); + // Both outcome stamps survive: `sent` inside the try, `failed: ` from the catch - so a + // relation load or render failure is stamped on the record exactly as a bounced mailbox is. + int stampSent = notification.indexOf("stampNotifyOutcome(entity.Id, null);"); + int stampFailed = notification.indexOf("stampNotifyOutcome(entity.Id, ex);"); + assertTrue(send < stampSent && stampSent < catches && catches < stampFailed, + "both delivery outcomes are still stamped, got: " + notification); + } + @Test void a_recurring_template_schedule_keys_on_the_period_of_the_run() { // Issue #7106: the recurring-template family had no key to declare. A monthly bill generated