From dacd60d5353f632488afa02d85913596bbd1ea5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Manero?= Date: Wed, 24 Sep 2025 09:56:30 +0200 Subject: [PATCH] Fixes #23129: Add system-default notification templates (#23441) * Setup system provided templates for ChangeEvent notifications * Setup handlebars helpers for system-default notification templates --- .../template/handlebars/HandlebarsHelper.java | 23 ++ .../handlebars/HandlebarsProvider.java | 58 +++++ .../helpers/BuildEntityUrlHelper.java | 78 +++++++ .../handlebars/helpers/DiffHelper.java | 86 ++++++++ .../handlebars/helpers/EndsWithHelper.java | 36 ++++ .../template/handlebars/helpers/EqHelper.java | 33 +++ .../helpers/FormatColumnValueHelper.java | 65 ++++++ .../handlebars/helpers/FormatDateHelper.java | 79 +++++++ .../helpers/GroupEventChangesHelper.java | 204 ++++++++++++++++++ .../handlebars/helpers/JoinListHelper.java | 61 ++++++ .../template/handlebars/helpers/OrHelper.java | 56 +++++ .../helpers/ResolveDomainHelper.java | 91 ++++++++ .../handlebars/helpers/SplitHelper.java | 38 ++++ .../handlebars/helpers/StartsWithHelper.java | 37 ++++ .../system-notification-entity-created.json | 9 + .../system-notification-entity-default.json | 9 + .../system-notification-entity-deleted.json | 9 + ...stem-notification-entity-soft-deleted.json | 9 + .../system-notification-entity-updated.json | 9 + ...-notification-logical-test-case-added.json | 9 + .../system-notification-post-created.json | 9 + .../system-notification-task-closed.json | 9 + .../system-notification-task-resolved.json | 9 + .../system-notification-test-result.json | 9 + .../system-notification-thread-created.json | 9 + .../system-notification-thread-updated.json | 9 + pom.xml | 2 +- 27 files changed, 1054 insertions(+), 1 deletion(-) create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/HandlebarsHelper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/HandlebarsProvider.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/BuildEntityUrlHelper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/DiffHelper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/EndsWithHelper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/EqHelper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/FormatColumnValueHelper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/FormatDateHelper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/GroupEventChangesHelper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/JoinListHelper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/OrHelper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/ResolveDomainHelper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/SplitHelper.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/StartsWithHelper.java create mode 100644 openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-created.json create mode 100644 openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-default.json create mode 100644 openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-deleted.json create mode 100644 openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-soft-deleted.json create mode 100644 openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-updated.json create mode 100644 openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-logical-test-case-added.json create mode 100644 openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-post-created.json create mode 100644 openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-task-closed.json create mode 100644 openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-task-resolved.json create mode 100644 openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-test-result.json create mode 100644 openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-thread-created.json create mode 100644 openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-thread-updated.json diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/HandlebarsHelper.java b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/HandlebarsHelper.java new file mode 100644 index 00000000000..57fc63aa354 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/HandlebarsHelper.java @@ -0,0 +1,23 @@ +package org.openmetadata.service.template.handlebars; + +import com.github.jknack.handlebars.Handlebars; + +/** + * Interface for all Handlebars helpers. Each helper should implement this interface to provide a + * consistent way of registering helpers with Handlebars. + */ +public interface HandlebarsHelper { + /** + * Get the name of this helper as it will be used in templates. + * + * @return The helper name + */ + String getName(); + + /** + * Register this helper with the given Handlebars instance. + * + * @param handlebars The Handlebars instance to register with + */ + void register(Handlebars handlebars); +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/HandlebarsProvider.java b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/HandlebarsProvider.java new file mode 100644 index 00000000000..b91a7b27f37 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/HandlebarsProvider.java @@ -0,0 +1,58 @@ +package org.openmetadata.service.template.handlebars; + +import com.github.jknack.handlebars.Handlebars; +import java.util.List; +import org.openmetadata.service.template.handlebars.helpers.BuildEntityUrlHelper; +import org.openmetadata.service.template.handlebars.helpers.DiffHelper; +import org.openmetadata.service.template.handlebars.helpers.EndsWithHelper; +import org.openmetadata.service.template.handlebars.helpers.EqHelper; +import org.openmetadata.service.template.handlebars.helpers.FormatColumnValueHelper; +import org.openmetadata.service.template.handlebars.helpers.FormatDateHelper; +import org.openmetadata.service.template.handlebars.helpers.GroupEventChangesHelper; +import org.openmetadata.service.template.handlebars.helpers.JoinListHelper; +import org.openmetadata.service.template.handlebars.helpers.OrHelper; +import org.openmetadata.service.template.handlebars.helpers.ResolveDomainHelper; +import org.openmetadata.service.template.handlebars.helpers.SplitHelper; +import org.openmetadata.service.template.handlebars.helpers.StartsWithHelper; + +/** + * Provider for Handlebars instances configured with all custom helpers for notification templates. + */ +public class HandlebarsProvider { + + private static final Handlebars INSTANCE = createInstance(); + + private HandlebarsProvider() { + /* Private constructor for singleton */ + } + + public static Handlebars getInstance() { + return INSTANCE; + } + + private static Handlebars createInstance() { + Handlebars handlebars = new Handlebars(); + + for (HandlebarsHelper helper : getAllHelpers()) { + helper.register(handlebars); + } + + return handlebars; + } + + private static List getAllHelpers() { + return List.of( + new JoinListHelper(), + new StartsWithHelper(), + new EndsWithHelper(), + new EqHelper(), + new OrHelper(), + new DiffHelper(), + new ResolveDomainHelper(), + new FormatDateHelper(), + new FormatColumnValueHelper(), + new GroupEventChangesHelper(), + new SplitHelper(), + new BuildEntityUrlHelper()); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/BuildEntityUrlHelper.java b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/BuildEntityUrlHelper.java new file mode 100644 index 00000000000..d167b6d37e6 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/BuildEntityUrlHelper.java @@ -0,0 +1,78 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openmetadata.service.template.handlebars.helpers; + +import com.github.jknack.handlebars.Handlebars; +import java.util.Map; +import java.util.Optional; +import okhttp3.HttpUrl; +import org.openmetadata.service.template.handlebars.HandlebarsHelper; +import org.openmetadata.service.util.email.EmailUtil; + +public class BuildEntityUrlHelper implements HandlebarsHelper { + + private static final String KEY_TYPE = "type"; + private static final String KEY_FQN = "fullyQualifiedName"; + + @Override + public String getName() { + return "buildEntityUrl"; + } + + @Override + public void register(Handlebars handlebars) { + handlebars.registerHelper( + getName(), + (entityRef, options) -> { + if (!(entityRef instanceof Map)) { + return null; + } + + @SuppressWarnings("unchecked") + Map refMap = (Map) entityRef; + + Optional typeOpt = + Optional.ofNullable(refMap.get(KEY_TYPE)) + .map(Object::toString) + .map(String::trim) + .filter(s -> !s.isEmpty()); + + Optional fqnOpt = + Optional.ofNullable(refMap.get(KEY_FQN)) + .map(Object::toString) + .map(String::trim) + .filter(s -> !s.isEmpty()); + + if (typeOpt.isEmpty() || fqnOpt.isEmpty()) { + return null; + } + + String baseUrl = EmailUtil.getOMBaseURL(); + if (baseUrl == null || baseUrl.isBlank()) { + return null; + } + + HttpUrl base = HttpUrl.parse(baseUrl); + if (base == null) { + return null; + } + + return base.newBuilder() + .addPathSegment(typeOpt.get()) + .addPathSegment(fqnOpt.get()) + .build() + .toString(); + }); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/DiffHelper.java b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/DiffHelper.java new file mode 100644 index 00000000000..faf3755f1d3 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/DiffHelper.java @@ -0,0 +1,86 @@ +package org.openmetadata.service.template.handlebars.helpers; + +import com.github.jknack.handlebars.Handlebars; +import com.github.jknack.handlebars.Options; +import java.util.LinkedList; +import org.bitbucket.cowwoc.diffmatchpatch.DiffMatchPatch; +import org.openmetadata.service.template.handlebars.HandlebarsHelper; + +/** + * Helper to generate inline diff with add/remove markers. Usage: {{diff oldValue newValue}} + * + *

Shows differences like: "This is old new text" + */ +public class DiffHelper implements HandlebarsHelper { + + private static final String INSERT_OPEN = ""; + private static final String INSERT_CLOSE = " "; + private static final String DELETE_OPEN = ""; + private static final String DELETE_CLOSE = " "; + private static final String SPACE = " "; + + @Override + public String getName() { + return "diff"; + } + + @Override + public void register(Handlebars handlebars) { + handlebars.registerHelper( + getName(), + (context, options) -> { + String oldValue = context != null ? context.toString() : ""; + String newValue = extractNewValue(options); + + return generateDiff(oldValue, newValue); + }); + } + + /** + * Extracts the new value from Handlebars options parameters. + * + * @param options Handlebars options containing parameters + * @return The new value string, or empty string if not present + */ + private String extractNewValue(Options options) { + if (options.params.length > 0 && options.param(0) != null) { + return options.param(0).toString(); + } + return ""; + } + + /** + * Generates an inline diff between old and new values using DiffMatchPatch. + * Inserts are marked with tags, deletions with tags. + * + * @param oldValue The original text + * @param newValue The new text + * @return HTML formatted diff string + */ + private String generateDiff(String oldValue, String newValue) { + DiffMatchPatch diffEngine = new DiffMatchPatch(); + LinkedList diffs = diffEngine.diffMain(oldValue, newValue); + diffEngine.diffCleanupSemantic(diffs); + + StringBuilder diffResult = new StringBuilder(); + + for (DiffMatchPatch.Diff diff : diffs) { + String trimmedText = diff.text.trim(); + + if (trimmedText.isEmpty()) { + continue; + } + + String formattedDiff = + switch (diff.operation) { + case EQUAL -> trimmedText + SPACE; + case INSERT -> INSERT_OPEN + trimmedText + INSERT_CLOSE; + case DELETE -> DELETE_OPEN + trimmedText + DELETE_CLOSE; + }; + + diffResult.append(formattedDiff); + } + + return diffResult.toString().trim(); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/EndsWithHelper.java b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/EndsWithHelper.java new file mode 100644 index 00000000000..27d962d0e9d --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/EndsWithHelper.java @@ -0,0 +1,36 @@ +package org.openmetadata.service.template.handlebars.helpers; + +import com.github.jknack.handlebars.Handlebars; +import com.github.jknack.handlebars.Helper; +import org.openmetadata.service.template.handlebars.HandlebarsHelper; + +/** + * Helper to check if a string ends with a given suffix. + * Usage: {{#if (endsWith name '.tags')}}...{{/if}} + */ +public class EndsWithHelper implements HandlebarsHelper { + + @Override + public String getName() { + return "endsWith"; + } + + @Override + public void register(Handlebars handlebars) { + handlebars.registerHelper( + getName(), + (Helper) + (text, options) -> { + if (text == null || options.params.length == 0) { + return false; + } + + Object suffixParam = options.param(0); + if (suffixParam == null) { + return false; + } + + return text.endsWith(suffixParam.toString()); + }); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/EqHelper.java b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/EqHelper.java new file mode 100644 index 00000000000..de3626ea05d --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/EqHelper.java @@ -0,0 +1,33 @@ +package org.openmetadata.service.template.handlebars.helpers; + +import com.github.jknack.handlebars.Handlebars; +import java.util.Objects; +import org.openmetadata.service.template.handlebars.HandlebarsHelper; + +/** + * Helper to check if two values are equal. + * Usage: {{#if (eq value1 value2)}}...{{/if}} + */ +public class EqHelper implements HandlebarsHelper { + + @Override + public String getName() { + return "eq"; + } + + @Override + public void register(Handlebars handlebars) { + handlebars.registerHelper( + getName(), + (context, options) -> { + if (context == null || options.params.length == 0) { + return false; + } + + Object firstParam = options.param(0); + + // Compare the context with the first parameter + return Objects.equals(context, firstParam); + }); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/FormatColumnValueHelper.java b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/FormatColumnValueHelper.java new file mode 100644 index 00000000000..ff81de52af0 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/FormatColumnValueHelper.java @@ -0,0 +1,65 @@ +package org.openmetadata.service.template.handlebars.helpers; + +import com.github.jknack.handlebars.Handlebars; +import java.util.Map; +import org.openmetadata.service.template.handlebars.HandlebarsHelper; + +/** + * Helper to format column values based on their type. Usage: {{formatColumnValue columnData}} + * + *

Formats column definitions as: columnName (type, constraints) + */ +public class FormatColumnValueHelper implements HandlebarsHelper { + + private static final String EMPTY_VALUE = "empty"; + + @Override + public String getName() { + return "formatColumnValue"; + } + + @Override + public void register(Handlebars handlebars) { + handlebars.registerHelper( + getName(), + (context, options) -> { + if (context == null) { + return EMPTY_VALUE; + } + + return switch (context) { + case Map columnMap -> formatColumnData(columnMap); + default -> context.toString(); + }; + }); + } + + /** + * Formats column data into a readable string representation. + * Format: columnName (dataType, constraint) or just columnName if no metadata. + * + * @param columnData Map containing column metadata + * @return Formatted column string + */ + private String formatColumnData(Map columnData) { + Object nameValue = columnData.get("name"); + Object dataTypeValue = columnData.get("dataType"); + Object constraintValue = columnData.get("constraint"); + + String columnName = nameValue != null ? nameValue.toString() : ""; + + if (dataTypeValue == null) { + return columnName; + } + + StringBuilder formattedColumn = new StringBuilder(columnName); + formattedColumn.append(" (").append(dataTypeValue); + + if (constraintValue != null) { + formattedColumn.append(", ").append(constraintValue); + } + + formattedColumn.append(")"); + return formattedColumn.toString(); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/FormatDateHelper.java b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/FormatDateHelper.java new file mode 100644 index 00000000000..de50f643f54 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/FormatDateHelper.java @@ -0,0 +1,79 @@ +package org.openmetadata.service.template.handlebars.helpers; + +import com.github.jknack.handlebars.Handlebars; +import java.text.SimpleDateFormat; +import java.util.Date; +import org.openmetadata.service.template.handlebars.HandlebarsHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Helper to format dates in a readable format. Usage: {{formatDate timestamp}} + * + *

Handles Long timestamps, Date objects, and String timestamps. + */ +public class FormatDateHelper implements HandlebarsHelper { + private static final Logger LOG = LoggerFactory.getLogger(FormatDateHelper.class); + private static final String DATE_FORMAT_PATTERN = "yyyy-MM-dd HH:mm:ss"; + private static final ThreadLocal DATE_FORMATTER = + ThreadLocal.withInitial(() -> new SimpleDateFormat(DATE_FORMAT_PATTERN)); + + @Override + public String getName() { + return "formatDate"; + } + + @Override + public void register(Handlebars handlebars) { + handlebars.registerHelper( + getName(), + (context, options) -> { + if (context == null) { + return ""; + } + + try { + Date dateToFormat = parseToDate(context); + if (dateToFormat != null) { + return DATE_FORMATTER.get().format(dateToFormat); + } + } catch (Exception e) { + LOG.warn("Error formatting date for value '{}': {}", context, e.getMessage()); + } + + return context.toString(); + }); + } + + /** + * Parses various input types to a Date object. + * Supports Long timestamps, Date objects, and String representations of timestamps. + * + * @param input The input object to parse + * @return Date object if parsing successful, null otherwise + */ + private Date parseToDate(Object input) { + return switch (input) { + case Long timestamp -> new Date(timestamp); + case Date date -> date; + case String stringValue -> parseStringToDate(stringValue); + default -> null; + }; + } + + /** + * Attempts to parse a string as a timestamp. + * + * @param stringValue The string to parse + * @return Date object if string is a valid timestamp, null otherwise + */ + private Date parseStringToDate(String stringValue) { + try { + long timestamp = Long.parseLong(stringValue); + return new Date(timestamp); + } catch (NumberFormatException e) { + // Not a timestamp string, return null to fall back to string representation + return null; + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/GroupEventChangesHelper.java b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/GroupEventChangesHelper.java new file mode 100644 index 00000000000..b4afdee3861 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/GroupEventChangesHelper.java @@ -0,0 +1,204 @@ +package org.openmetadata.service.template.handlebars.helpers; + +import com.github.jknack.handlebars.Handlebars; +import com.github.jknack.handlebars.Helper; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.BitSet; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import org.openmetadata.schema.type.ChangeDescription; +import org.openmetadata.schema.type.FieldChange; +import org.openmetadata.service.template.handlebars.HandlebarsHelper; + +/** + * Helper to group change events by merging adds/deletes into updates where applicable. + * Matches FormatterUtil.getFormattedMessages() grouping. + * + * Template usage: + * {{#with (groupEventChanges event.changeDescription) as |changes|}} + * {{#if changes.updates}}...{{/if}} + * {{#if changes.adds}}...{{/if}} + * {{#if changes.deletes}}...{{/if}} + * {{/with}} + */ +public class GroupEventChangesHelper implements HandlebarsHelper { + + private record Indexed(T value, int index) {} + + private record MatchResult(List mergedUpdates, BitSet matchedDeleteIdx) {} + + @Override + public String getName() { + return "groupEventChanges"; + } + + @Override + public void register(Handlebars handlebars) { + handlebars.registerHelper( + getName(), (Helper) (changeDesc, options) -> groupChanges(changeDesc)); + } + + private ChangeGroups groupChanges(ChangeDescription changeDesc) { + // Extract all field changes from the change description + final List fieldsUpdated = extractFieldsUpdated(changeDesc); + final List fieldsAdded = extractFieldsAdded(changeDesc); + final List fieldsDeleted = extractFieldsDeleted(changeDesc); + + // Build the initial update list from direct updates + final List updates = new ArrayList<>(buildDirectUpdates(fieldsUpdated)); + + // Build indexed queues for efficient lookup of adds by field name + final Map>> addedQueues = buildAddedQueues(fieldsAdded); + final BitSet consumedAddedIdx = new BitSet(fieldsAdded.size()); + + // Match deletes with adds to create merged updates + final MatchResult matchResult = + matchDeletesWithAdds(fieldsDeleted, addedQueues, consumedAddedIdx); + + // Collect unmatched deletes + final List unmatchedDeletes = + collectUnmatchedDeletes(fieldsDeleted, matchResult.matchedDeleteIdx()); + + // Collect remaining adds that weren't consumed + final List remainingAdds = collectRemainingAdds(fieldsAdded, consumedAddedIdx); + + // Combine all updates (direct + merged) + updates.addAll(matchResult.mergedUpdates()); + + return new ChangeGroups(updates, remainingAdds, unmatchedDeletes); + } + + private List buildDirectUpdates(List fieldsUpdated) { + return fieldsUpdated.stream() + .map( + u -> + new FieldChange() + .withName(u.getName()) + .withOldValue(u.getOldValue()) + .withNewValue(u.getNewValue())) + .toList(); + } + + private MatchResult matchDeletesWithAdds( + List fieldsDeleted, + Map>> addedQueues, + BitSet consumedAddedIdx) { + + final List mergedUpdates = new ArrayList<>(); + final BitSet matchedDeleteIdx = new BitSet(fieldsDeleted.size()); + + for (int i = 0; i < fieldsDeleted.size(); i++) { + final FieldChange delete = fieldsDeleted.get(i); + final Deque> queue = addedQueues.get(delete.getName()); + if (queue != null && !queue.isEmpty()) { + // Found matching add - consume it and create merged update + final Indexed consumed = queue.removeFirst(); + final FieldChange matchedAdd = consumed.value(); + consumedAddedIdx.set(consumed.index()); + + // Mark all duplicate adds as consumed + markDuplicatesAsConsumed(queue, matchedAdd, consumedAddedIdx); + + // Create merged update + mergedUpdates.add( + new FieldChange() + .withName(delete.getName()) + .withOldValue(delete.getOldValue()) + .withNewValue(matchedAdd.getNewValue())); + + matchedDeleteIdx.set(i); // Remember this delete was matched + } + } + + return new MatchResult(mergedUpdates, matchedDeleteIdx); + } + + private List collectUnmatchedDeletes( + List fieldsDeleted, BitSet matchedDeleteIdx) { + + final List unmatchedDeletes = new ArrayList<>(); + + for (int i = 0; i < fieldsDeleted.size(); i++) { + if (!matchedDeleteIdx.get(i)) { + final FieldChange delete = fieldsDeleted.get(i); + unmatchedDeletes.add( + new FieldChange() + .withName(delete.getName()) + .withOldValue(delete.getOldValue()) + .withNewValue(null)); + } + } + + return unmatchedDeletes; + } + + private List collectRemainingAdds( + List fieldsAdded, BitSet consumedAddedIdx) { + + final List remainingAdds = new ArrayList<>(); + + for (int i = 0; i < fieldsAdded.size(); i++) { + if (!consumedAddedIdx.get(i)) { + final FieldChange add = fieldsAdded.get(i); + remainingAdds.add( + new FieldChange() + .withName(add.getName()) + .withOldValue(null) + .withNewValue(add.getNewValue())); + } + } + + return remainingAdds; + } + + private void markDuplicatesAsConsumed( + Deque> queue, FieldChange matchedAdd, BitSet consumedAddedIdx) { + + queue.removeIf( + ix -> { + if (Objects.equals(ix.value(), matchedAdd)) { + consumedAddedIdx.set(ix.index()); + return true; + } + return false; + }); + } + + private List extractFieldsUpdated(ChangeDescription changeDesc) { + return Optional.ofNullable(changeDesc) + .map(ChangeDescription::getFieldsUpdated) + .orElse(List.of()); + } + + private List extractFieldsAdded(ChangeDescription changeDesc) { + return Optional.ofNullable(changeDesc).map(ChangeDescription::getFieldsAdded).orElse(List.of()); + } + + private List extractFieldsDeleted(ChangeDescription changeDesc) { + return Optional.ofNullable(changeDesc) + .map(ChangeDescription::getFieldsDeleted) + .orElse(List.of()); + } + + private Map>> buildAddedQueues(List fieldsAdded) { + final Map>> addedQueues = new LinkedHashMap<>(); + for (int i = 0; i < fieldsAdded.size(); i++) { + final FieldChange fc = fieldsAdded.get(i); + addedQueues + .computeIfAbsent(fc.getName(), k -> new ArrayDeque<>()) + .addLast(new Indexed<>(fc, i)); + } + return addedQueues; + } + + /** + * Container for grouped changes that the template can access + */ + public record ChangeGroups( + List updates, List adds, List deletes) {} +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/JoinListHelper.java b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/JoinListHelper.java new file mode 100644 index 00000000000..ebe70fcdb63 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/JoinListHelper.java @@ -0,0 +1,61 @@ +package org.openmetadata.service.template.handlebars.helpers; + +import com.github.jknack.handlebars.Handlebars; +import java.util.Collection; +import java.util.Map; +import java.util.stream.Collectors; +import org.openmetadata.schema.type.TagLabel; +import org.openmetadata.service.template.handlebars.HandlebarsHelper; + +/** + * Helper to join lists into comma-separated strings. Usage: {{joinList tags}} + * + *

Handles TagLabel objects specially by extracting their FQN. + */ +public class JoinListHelper implements HandlebarsHelper { + + @Override + public String getName() { + return "joinList"; + } + + @Override + public void register(Handlebars handlebars) { + handlebars.registerHelper( + getName(), + (context, options) -> { + if (context == null) { + return ""; + } + + if (context instanceof Collection collection) { + return collection.stream() + .map(this::extractDisplayValue) + .collect(Collectors.joining(", ")); + } + + return context.toString(); + }); + } + + /** + * Extracts the display value from an item based on its type. + * For TagLabel objects, returns the tag FQN. + * For Maps with tagFQN field, returns that value. + * Otherwise returns the string representation. + */ + private String extractDisplayValue(Object item) { + return switch (item) { + case TagLabel tagLabel -> { + String fqn = tagLabel.getTagFQN(); + yield fqn != null ? fqn : ""; + } + case Map map -> { + Object tagFQN = map.get("tagFQN"); + yield tagFQN != null ? tagFQN.toString() : item.toString(); + } + case null -> ""; + default -> item.toString(); + }; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/OrHelper.java b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/OrHelper.java new file mode 100644 index 00000000000..011f06a7d66 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/OrHelper.java @@ -0,0 +1,56 @@ +package org.openmetadata.service.template.handlebars.helpers; + +import com.github.jknack.handlebars.Handlebars; +import org.openmetadata.service.template.handlebars.HandlebarsHelper; + +/** + * Helper to check if any of the values are truthy. + * Usage: {{#if (or value1 value2 value3)}}...{{/if}} + */ +public class OrHelper implements HandlebarsHelper { + + @Override + public String getName() { + return "or"; + } + + @Override + public void register(Handlebars handlebars) { + handlebars.registerHelper( + getName(), + (context, options) -> { + // Check if context is truthy + if (isTruthy(context)) { + return true; + } + + // Check if any parameter is truthy + if (options.params != null) { + for (Object param : options.params) { + if (isTruthy(param)) { + return true; + } + } + } + + return false; + }); + } + + private boolean isTruthy(Object value) { + if (value == null) { + return false; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value instanceof String) { + return !((String) value).isEmpty(); + } + if (value instanceof Number) { + return ((Number) value).doubleValue() != 0; + } + // Non-null objects are considered truthy + return true; + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/ResolveDomainHelper.java b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/ResolveDomainHelper.java new file mode 100644 index 00000000000..fc8bf0175bc --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/ResolveDomainHelper.java @@ -0,0 +1,91 @@ +package org.openmetadata.service.template.handlebars.helpers; + +import com.github.jknack.handlebars.Handlebars; +import java.util.Map; +import java.util.UUID; +import org.openmetadata.schema.entity.domains.Domain; +import org.openmetadata.schema.type.Include; +import org.openmetadata.service.Entity; +import org.openmetadata.service.template.handlebars.HandlebarsHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Helper to resolve domain references to display names. Usage: {{resolveDomain domainRef}} + * + *

Handles both EntityReference objects and UUID strings. + */ +public class ResolveDomainHelper implements HandlebarsHelper { + private static final Logger LOG = LoggerFactory.getLogger(ResolveDomainHelper.class); + private static final String NO_DOMAIN = "(no domain)"; + + @Override + public String getName() { + return "resolveDomain"; + } + + @Override + public void register(Handlebars handlebars) { + handlebars.registerHelper( + getName(), + (context, options) -> { + if (context == null) { + return NO_DOMAIN; + } + + return switch (context) { + case Map entityReference -> { + String resolved = resolveFromEntityReference(entityReference); + yield resolved != null ? resolved : context.toString(); + } + case String domainId -> resolveFromString(domainId); + default -> context.toString(); + }; + }); + } + + /** + * Resolves domain name from an EntityReference map. + * Prioritizes displayName, then name, then fullyQualifiedName. + * + * @param entityReference Map containing entity reference fields + * @return Resolved domain name or null if not found + */ + private String resolveFromEntityReference(Map entityReference) { + Object displayName = entityReference.get("displayName"); + Object name = entityReference.get("name"); + Object fullyQualifiedName = entityReference.get("fullyQualifiedName"); + + if (displayName != null && !displayName.toString().isEmpty()) { + return displayName.toString(); + } + + if (name != null && !name.toString().isEmpty()) { + return name.toString(); + } + + if (fullyQualifiedName != null) { + return fullyQualifiedName.toString(); + } + + return null; + } + + /** + * Resolves domain from a string UUID. + * Attempts to fetch the domain entity and return its fully qualified name. + * + * @param domainId String representation of domain UUID + * @return Domain's fully qualified name or the original ID if resolution fails + */ + private String resolveFromString(String domainId) { + try { + Domain domain = + Entity.getEntity(Entity.DOMAIN, UUID.fromString(domainId), "id", Include.NON_DELETED); + return domain.getFullyQualifiedName(); + } catch (Exception e) { + LOG.debug("Could not resolve domain ID {}: {}", domainId, e.getMessage()); + return domainId; + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/SplitHelper.java b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/SplitHelper.java new file mode 100644 index 00000000000..ec0bad5170e --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/SplitHelper.java @@ -0,0 +1,38 @@ +package org.openmetadata.service.template.handlebars.helpers; + +import com.github.jknack.handlebars.Handlebars; +import java.util.regex.Pattern; +import org.openmetadata.service.template.handlebars.HandlebarsHelper; + +/** + * Helper to split a string by a delimiter. + * Usage: {{#with (split fieldName '.')}}{{.[1]}}{{/with}} + */ +public class SplitHelper implements HandlebarsHelper { + + @Override + public String getName() { + return "split"; + } + + @Override + public void register(Handlebars handlebars) { + handlebars.registerHelper( + getName(), + (context, options) -> { + if (context == null || options.params.length == 0) { + return new String[0]; + } + + String textToSplit = context.toString(); + Object delimiterParam = options.param(0); + + if (delimiterParam == null) { + return new String[] {textToSplit}; + } + + String delimiter = delimiterParam.toString(); + return textToSplit.split(Pattern.quote(delimiter)); + }); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/StartsWithHelper.java b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/StartsWithHelper.java new file mode 100644 index 00000000000..49f943678b9 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/template/handlebars/helpers/StartsWithHelper.java @@ -0,0 +1,37 @@ +package org.openmetadata.service.template.handlebars.helpers; + +import com.github.jknack.handlebars.Handlebars; +import org.openmetadata.service.template.handlebars.HandlebarsHelper; + +/** + * Helper to check if a string starts with a prefix. + * Usage: {{#if (startsWith name 'columns.')}}...{{/if}} + */ +public class StartsWithHelper implements HandlebarsHelper { + + @Override + public String getName() { + return "startsWith"; + } + + @Override + public void register(Handlebars handlebars) { + handlebars.registerHelper( + getName(), + (context, options) -> { + if (context == null || options.params.length == 0) { + return false; + } + + Object firstParam = options.param(0); + if (firstParam == null) { + return false; + } + + String textToCheck = context.toString(); + String prefix = firstParam.toString(); + + return textToCheck.startsWith(prefix); + }); + } +} diff --git a/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-created.json b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-created.json new file mode 100644 index 00000000000..6ed8bc0b381 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-created.json @@ -0,0 +1,9 @@ +{ + "name": "system-notification-entity-created", + "fullyQualifiedName": "system-notification-entity-created", + "displayName": "Entity Created Notification", + "description": "System default template for entity creation notifications", + "provider": "system", + "templateSubject": "{{entity.entityType}} {{entity.name}} created", + "templateBody": "{{#if (eq channel 'email')}}Hello {{recipientName}},

{{/if}}[{{publisherName}}] {{event.userName}} created {{entity.entityType}} {{entity.fullyQualifiedName}}{{#if entity.description}}
Description: {{entity.description}}{{/if}}{{#if (eq channel 'email')}}

Happy Exploring!
Thanks.{{/if}}" +} \ No newline at end of file diff --git a/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-default.json b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-default.json new file mode 100644 index 00000000000..86d5c372c88 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-default.json @@ -0,0 +1,9 @@ +{ + "name": "system-notification-entity-default", + "fullyQualifiedName": "system-notification-entity-default", + "displayName": "Default Entity Notification", + "description": "System default fallback template for any unhandled eventTypes", + "provider": "system", + "templateSubject": "{{event.eventType}} - {{entity.fullyQualifiedName}}", + "templateBody": "{{#if (eq channel 'email')}}Hello {{recipientName}},

{{/if}}[{{publisherName}}] {{event.userName}} performed {{event.eventType}} on {{entity.fullyQualifiedName}}{{#if changes.fieldsUpdated}}
Changed: {{#each changes.fieldsUpdated}}{{name}}{{#unless @last}}, {{/unless}}{{/each}}{{/if}}{{#if (eq channel 'email')}}

Happy Exploring!
Thanks.{{/if}}" +} \ No newline at end of file diff --git a/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-deleted.json b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-deleted.json new file mode 100644 index 00000000000..bf0ae50f66d --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-deleted.json @@ -0,0 +1,9 @@ +{ + "name": "system-notification-entity-deleted", + "fullyQualifiedName": "system-notification-entity-deleted", + "displayName": "Entity Deleted Notification", + "description": "System default template for entity permanent deletion notifications", + "provider": "system", + "templateSubject": "{{entity.entityType}} {{entity.name}} deleted", + "templateBody": "{{#if (eq channel 'email')}}Hello {{recipientName}},

{{/if}}[{{publisherName}}] {{event.userName}} deleted {{entity.entityType}} {{entity.fullyQualifiedName}}{{#if (eq channel 'email')}}

Happy Exploring!
Thanks.{{/if}}" +} \ No newline at end of file diff --git a/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-soft-deleted.json b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-soft-deleted.json new file mode 100644 index 00000000000..d67240b44b1 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-soft-deleted.json @@ -0,0 +1,9 @@ +{ + "name": "system-notification-entity-soft-deleted", + "fullyQualifiedName": "system-notification-entity-soft-deleted", + "displayName": "Entity Soft Deleted Notification", + "description": "System default template for entity soft deletion notifications", + "provider": "system", + "templateSubject": "{{entity.entityType}} {{entity.name}} soft deleted", + "templateBody": "{{#if (eq channel 'email')}}Hello {{recipientName}},

{{/if}}[{{publisherName}}] {{event.userName}} soft deleted {{entity.entityType}} {{entity.fullyQualifiedName}}{{#if (eq channel 'email')}}

Happy Exploring!
Thanks.{{/if}}" +} \ No newline at end of file diff --git a/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-updated.json b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-updated.json new file mode 100644 index 00000000000..0cd6fbda84b --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-entity-updated.json @@ -0,0 +1,9 @@ +{ + "name": "system-notification-entity-updated", + "fullyQualifiedName": "system-notification-entity-updated", + "displayName": "Entity Updated Notification", + "description": "System default template for entity update notifications", + "provider": "system", + "templateSubject": "{{event.entityType}} {{entity.name}} updated", + "templateBody": "{{#if (eq channel 'email')}}Hello {{recipientName}},

{{/if}}[{{publisherName}}] {{event.userName}} updated {{event.entityType}} {{entity.fullyQualifiedName}}{{#with (groupEventChanges event.changeDescription) as |changes|}}{{#if changes.updates}}
Changed fields:

    {{#each changes.updates}}
  • Updated {{#if (startsWith name 'columns.')}}{{#with (split name '.') as |parts|}}{{#if (endsWith name '.tags')}}tags for column {{lookup parts 1}}{{else}}{{lookup parts 2}} for column {{lookup parts 1}}{{/if}}{{/with}}{{else if (startsWith name 'extension.')}}{{#with (split name '.') as |parts|}}custom property '{{lookup parts 1}}'{{/with}}{{else}}{{name}}{{/if}}: {{#if (or (eq name 'tags') (endsWith name '.tags'))}}{{#if oldValue}}{{joinList oldValue}}{{else}}empty{{/if}} → {{#if newValue}}{{joinList newValue}}{{else}}empty{{/if}}{{else if (eq name 'owner')}}{{#if oldValue}}{{#if oldValue.displayName}}{{oldValue.displayName}}{{else if oldValue.name}}{{oldValue.name}}{{else}}{{oldValue}}{{/if}}{{else}}No Owner{{/if}} → {{#if newValue}}{{#if newValue.displayName}}{{newValue.displayName}}{{else if newValue.name}}{{newValue.name}}{{else}}{{newValue}}{{/if}}{{else}}No Owner{{/if}}{{else if (eq name 'owners')}}{{#each oldValue}}{{#if displayName}}{{displayName}}{{else if name}}{{name}}{{else}}{{this}}{{/if}}{{#unless @last}}, {{/unless}}{{else}}No Owner{{/each}} → {{#each newValue}}{{#if displayName}}{{displayName}}{{else if name}}{{name}}{{else}}{{this}}{{/if}}{{#unless @last}}, {{/unless}}{{else}}No Owner{{/each}}{{else if (eq name 'domain')}}{{resolveDomain oldValue}} → {{#with (buildEntityUrl newValue) as |url|}}{{resolveDomain newValue}}{{else}}{{resolveDomain newValue}}{{/with}}{{else if (eq name 'domains')}}{{#each oldValue}}{{resolveDomain this}}{{#unless @last}}, {{/unless}}{{else}}No Domain{{/each}} → {{#each newValue as |domain|}}{{#with (buildEntityUrl domain) as |url|}}{{resolveDomain domain}}{{else}}{{resolveDomain domain}}{{/with}}{{#unless @last}}, {{/unless}}{{else}}No Domain{{/each}}{{else if (eq name 'description')}}{{diff oldValue newValue}}{{else if (startsWith name 'extension.')}}{{#if oldValue}}{{oldValue}}{{else}}Not Set{{/if}} → {{#if newValue}}{{newValue}}{{else}}Not Set{{/if}}{{else if (eq name 'assets')}}{{#each oldValue}}{{#if displayName}}{{displayName}}{{else if name}}{{name}}{{else}}{{this}}{{/if}}{{#unless @last}}, {{/unless}}{{else}}No assets{{/each}} → {{#each newValue}}{{#if displayName}}{{displayName}}{{else if name}}{{name}}{{else}}{{this}}{{/if}}{{#unless @last}}, {{/unless}}{{else}}No assets{{/each}}{{else if (eq name 'testCaseResult')}}{{#if newValue}}{{#if newValue.testCaseStatus}}Test Case {{#if @root.entity.displayName}}{{@root.entity.displayName}}{{else}}{{@root.entity.name}}{{/if}} is {{#if (eq newValue.testCaseStatus 'Success')}}Passed{{else if (eq newValue.testCaseStatus 'Failed')}}Failed{{else if (eq newValue.testCaseStatus 'Aborted')}}Aborted{{else if (eq newValue.testCaseStatus 'Queued')}}Queued{{else}}{{newValue.testCaseStatus}}{{/if}} in {{@root.entity.fullyQualifiedName}}{{else}}Test Case {{#if @root.entity.displayName}}{{@root.entity.displayName}}{{else}}{{@root.entity.name}}{{/if}} is updated in {{@root.entity.fullyQualifiedName}}{{/if}}{{else}}Test Case {{#if @root.entity.displayName}}{{@root.entity.displayName}}{{else}}{{@root.entity.name}}{{/if}} is updated in {{@root.entity.fullyQualifiedName}}{{/if}}{{else if (eq name 'kpiResult')}}{{#if newValue}}{{#if newValue.targetResult}}{{#with (lookup newValue.targetResult 0) as |result|}}Added Results for {{@root.entity.name}}. Target Name: {{result.name}}, Current Value: {{result.value}}, Target Met: {{result.targetMet}}{{/with}}{{else}}KpiResult {{@root.entity.name}} is updated{{/if}}{{else}}KpiResult {{@root.entity.name}} is updated{{/if}}{{else if (eq name 'pipelineStatus')}}{{#if (eq @root.event.entityType 'ingestionPipeline')}}{{#if newValue}}Ingestion Pipeline {{@root.entity.name}} {{newValue.pipelineState}} at {{formatDate newValue.timestamp}}{{else}}Ingestion Pipeline {{@root.entity.name}} is updated{{/if}}{{else if (eq @root.event.entityType 'pipeline')}}{{#if newValue}}Pipeline {{@root.entity.name}} {{newValue.executionStatus}} at {{formatDate newValue.timestamp}}{{else}}Pipeline {{@root.entity.name}} is updated{{/if}}{{else}}{{#if oldValue}}{{oldValue}}{{else}}empty{{/if}} → {{#if newValue}}{{newValue}}{{else}}empty{{/if}}{{/if}}{{else if (eq name 'queryUsedIn')}}{{#if @root.entity.query}}for '{{@root.entity.query}}',
    {{/if}}Query Used in: {{#each oldValue as |entity|}}{{#with (buildEntityUrl entity) as |url|}}{{entity.fullyQualifiedName}}{{else}}{{entity.fullyQualifiedName}}{{/with}}{{#unless @last}}, {{/unless}}{{else}}Not used{{/each}} → {{#if @root.entity.query}}for '{{@root.entity.query}}',
    {{/if}}Query Used in: {{#each newValue as |entity|}}{{#with (buildEntityUrl entity) as |url|}}{{entity.fullyQualifiedName}}{{else}}{{entity.fullyQualifiedName}}{{/with}}{{#unless @last}}, {{/unless}}{{else}}Not used{{/each}}{{else if (startsWith name 'columns.')}}{{formatColumnValue oldValue}} → {{formatColumnValue newValue}}{{else if (startsWith name 'customProperties.')}}{{#if oldValue}}{{oldValue}}{{else}}Not Set{{/if}} → {{#if newValue}}{{newValue}}{{else}}Not Set{{/if}}{{else}}{{#if oldValue}}{{oldValue}}{{else}}empty{{/if}} → {{#if newValue}}{{newValue}}{{else}}empty{{/if}}{{/if}}
  • {{/each}}
{{/if}}{{#if changes.adds}}
Added fields:
    {{#each changes.adds}}
  • {{#if (eq name 'followers')}}Followed {{@root.event.entityType}} {{@root.entity.fullyQualifiedName}}{{else if (startsWith name 'columns.')}}{{#with (split name '.') as |parts|}}{{#if (endsWith name '.tags')}}Added tags to column {{lookup parts 1}}: {{joinList newValue}}{{else}}Added {{lookup parts 2}} to column {{lookup parts 1}}: {{newValue}}{{/if}}{{/with}}{{else if (eq name 'tags')}}tags: {{joinList newValue}}{{else if (startsWith name 'extension.')}}{{#with (split name '.') as |parts|}}Added custom property '{{lookup parts 1}}': {{newValue}}{{/with}}{{else if (eq name 'owner')}}owner: {{#if newValue.displayName}}{{newValue.displayName}}{{else if newValue.name}}{{newValue.name}}{{else}}{{newValue}}{{/if}}{{else if (eq name 'owners')}}owners: {{#each newValue}}{{#if displayName}}{{displayName}}{{else if name}}{{name}}{{else}}{{this}}{{/if}}{{#unless @last}}, {{/unless}}{{/each}}{{else if (eq name 'domain')}}domain: {{#with (buildEntityUrl newValue) as |url|}}{{resolveDomain newValue}}{{else}}{{resolveDomain newValue}}{{/with}}{{else if (eq name 'domains')}}domains: {{#each newValue as |domain|}}{{#with (buildEntityUrl domain) as |url|}}{{resolveDomain domain}}{{else}}{{resolveDomain domain}}{{/with}}{{#unless @last}}, {{/unless}}{{/each}}{{else if (eq name 'description')}}description: {{newValue}}{{else if (eq name 'queryUsedIn')}}queryUsedIn: {{#if @root.entity.query}}for '{{@root.entity.query}}',
    {{/if}}Query Used in: {{#each newValue as |entity|}}{{#with (buildEntityUrl entity) as |url|}}{{entity.fullyQualifiedName}}{{else}}{{entity.fullyQualifiedName}}{{/with}}{{#unless @last}}, {{/unless}}{{/each}}{{else if (eq name 'assets')}}assets: {{#each newValue}}{{#if displayName}}{{displayName}}{{else if name}}{{name}}{{else}}{{this}}{{/if}}{{#unless @last}}, {{/unless}}{{/each}}{{else}}{{name}}: {{newValue}}{{/if}}
  • {{/each}}
{{/if}}{{#if changes.deletes}}
Removed fields:
    {{#each changes.deletes}}
  • {{#if (eq name 'followers')}}Unfollowed {{@root.event.entityType}} {{@root.entity.fullyQualifiedName}}{{else if (startsWith name 'columns.')}}{{#with (split name '.') as |parts|}}{{#if (endsWith name '.tags')}}Deleted tags from column {{lookup parts 1}}: {{joinList oldValue}}{{else}}Deleted {{lookup parts 2}} from column {{lookup parts 1}}: {{oldValue}}{{/if}}{{/with}}{{else if (eq name 'tags')}}tags: {{joinList oldValue}}{{else if (startsWith name 'extension.')}}{{#with (split name '.') as |parts|}}Deleted custom property '{{lookup parts 1}}': {{oldValue}}{{/with}}{{else if (eq name 'owner')}}owner: {{#if oldValue.displayName}}{{oldValue.displayName}}{{else if oldValue.name}}{{oldValue.name}}{{else}}{{oldValue}}{{/if}}{{else if (eq name 'owners')}}owners: {{#each oldValue}}{{#if displayName}}{{displayName}}{{else if name}}{{name}}{{else}}{{this}}{{/if}}{{#unless @last}}, {{/unless}}{{/each}}{{else if (eq name 'domain')}}domain: {{resolveDomain oldValue}}{{else if (eq name 'domains')}}domains: {{#each oldValue}}{{resolveDomain this}}{{#unless @last}}, {{/unless}}{{/each}}{{else if (eq name 'description')}}description: {{oldValue}}{{else if (eq name 'queryUsedIn')}}queryUsedIn: {{#if @root.entity.query}}for '{{@root.entity.query}}',
    {{/if}}Query Used in was removed{{else if (eq name 'assets')}}assets: {{#each oldValue}}{{#if displayName}}{{displayName}}{{else if name}}{{name}}{{else}}{{this}}{{/if}}{{#unless @last}}, {{/unless}}{{/each}}{{else}}{{name}}: {{oldValue}}{{/if}}
  • {{/each}}
{{/if}}{{/with}}{{#if (eq channel 'email')}}

Happy Exploring!
Thanks.{{/if}}" +} \ No newline at end of file diff --git a/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-logical-test-case-added.json b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-logical-test-case-added.json new file mode 100644 index 00000000000..5d01c7ebc8a --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-logical-test-case-added.json @@ -0,0 +1,9 @@ +{ + "name": "system-notification-logical-test-case-added", + "fullyQualifiedName": "system-notification-logical-test-case-added", + "displayName": "Logical Test Case Added Notification", + "description": "System default template for logical test case addition notifications", + "provider": "system", + "templateSubject": "Test cases added to {{entity.fullyQualifiedName}}", + "templateBody": "{{#if (eq channel 'email')}}Hello {{recipientName}},

{{/if}}[{{publisherName}}] {{event.userName}} added logical test cases to {{entity.fullyQualifiedName}}{{#if (eq channel 'email')}}

Happy Exploring!
Thanks.{{/if}}" +} \ No newline at end of file diff --git a/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-post-created.json b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-post-created.json new file mode 100644 index 00000000000..6ae769391ec --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-post-created.json @@ -0,0 +1,9 @@ +{ + "name": "system-notification-post-created", + "fullyQualifiedName": "system-notification-post-created", + "displayName": "Post Created Notification", + "description": "System default template for new post/reply notifications", + "provider": "system", + "templateSubject": "New reply in {{thread.type}} - {{entity.fullyQualifiedName}}", + "templateBody": "{{#if (eq channel 'email')}}Hello {{recipientName}},

{{/if}}{{#if (eq thread.type 'Task')}}[{{publisherName}}] @{{thread.createdBy}} posted a message on the Task with Id: {{thread.task.id}} for Asset {{entity.fullyQualifiedName}}{{#each thread.posts}}
@{{from}}: {{message}}{{/each}}{{else if (eq thread.type 'Announcement')}}[{{publisherName}}] @{{thread.createdBy}} posted a message on Announcement{{#each thread.posts}}
@{{from}}: {{message}}{{/each}}{{else}}[{{publisherName}}] @{{thread.createdBy}} posted a message on asset {{entity.fullyQualifiedName}}{{#each thread.posts}}
@{{from}}: {{message}}{{/each}}{{/if}}{{#if (eq channel 'email')}}

Happy Exploring!
Thanks.{{/if}}" +} \ No newline at end of file diff --git a/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-task-closed.json b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-task-closed.json new file mode 100644 index 00000000000..8cb4fc1dcec --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-task-closed.json @@ -0,0 +1,9 @@ +{ + "name": "system-notification-task-closed", + "fullyQualifiedName": "system-notification-task-closed", + "displayName": "Task Closed Notification", + "description": "System default template for task closure notifications", + "provider": "system", + "templateSubject": "Task closed - {{entity.fullyQualifiedName}}", + "templateBody": "{{#if (eq channel 'email')}}Hello {{recipientName}},

{{/if}}[{{publisherName}}] @{{thread.createdBy}} closed Task with Id: {{thread.task.id}} for Asset {{entity.fullyQualifiedName}}
Current Status: Closed{{#if (eq channel 'email')}}

Happy Exploring!
Thanks.{{/if}}" +} \ No newline at end of file diff --git a/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-task-resolved.json b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-task-resolved.json new file mode 100644 index 00000000000..481cc7272ee --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-task-resolved.json @@ -0,0 +1,9 @@ +{ + "name": "system-notification-task-resolved", + "fullyQualifiedName": "system-notification-task-resolved", + "displayName": "Task Resolved Notification", + "description": "System default template for task resolution notifications", + "provider": "system", + "templateSubject": "Task resolved - {{entity.fullyQualifiedName}}", + "templateBody": "{{#if (eq channel 'email')}}Hello {{recipientName}},

{{/if}}[{{publisherName}}] @{{thread.createdBy}} resolved Task with Id: {{thread.task.id}} for Asset {{entity.fullyQualifiedName}}
Current Status: Resolved{{#if (eq channel 'email')}}

Happy Exploring!
Thanks.{{/if}}" +} \ No newline at end of file diff --git a/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-test-result.json b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-test-result.json new file mode 100644 index 00000000000..1aabb5ef1ed --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-test-result.json @@ -0,0 +1,9 @@ +{ + "name": "system-notification-test-result", + "fullyQualifiedName": "system-notification-test-result", + "displayName": "Test Result Notification", + "description": "System default template for test case execution result notifications", + "provider": "system", + "templateSubject": "Test {{testResult.testCaseStatus}} - {{entity.fullyQualifiedName}}", + "templateBody": "{{#if (eq channel 'email')}}Hello {{recipientName}},

{{/if}}Test Result Update
Test Case: {{entity.fullyQualifiedName}}
Status: {{#if (eq testResult.testCaseStatus 'Success')}}✓ Passed{{else if (eq testResult.testCaseStatus 'Failed')}}✗ Failed{{else if (eq testResult.testCaseStatus 'Aborted')}}⚠ Aborted{{else}}⏳ {{testResult.testCaseStatus}}{{/if}}{{#if testResult.result}}
Result: {{testResult.result}}{{/if}}{{#if testResult.testResultValue}}
Value: {{testResult.testResultValue}}{{/if}}
Timestamp: {{formatDate testResult.timestamp}}{{#if entity.description}}
Test Description: {{entity.description}}{{/if}}{{#if (eq channel 'email')}}

Happy Exploring!
Thanks.{{/if}}" +} \ No newline at end of file diff --git a/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-thread-created.json b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-thread-created.json new file mode 100644 index 00000000000..e5084e400ce --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-thread-created.json @@ -0,0 +1,9 @@ +{ + "name": "system-notification-thread-created", + "fullyQualifiedName": "system-notification-thread-created", + "displayName": "Thread Created Notification", + "description": "System default template for new conversation/task/announcement creation notifications", + "provider": "system", + "templateSubject": "New {{thread.type}} - {{entity.fullyQualifiedName}}", + "templateBody": "{{#if (eq channel 'email')}}Hello {{recipientName}},

{{/if}}{{#if (eq thread.type 'Conversation')}}[{{publisherName}}] @{{thread.createdBy}} started a conversation for asset {{entity.fullyQualifiedName}}
{{thread.message}}{{else if (eq thread.type 'Task')}}[{{publisherName}}] @{{thread.createdBy}} created a Task for {{entity.entityType}} {{entity.fullyQualifiedName}}
Task Type: {{thread.task.type}}
Assignees: {{#each thread.task.assignees}}@{{name}}{{#unless @last}}, {{/unless}}{{/each}}
Current Status: {{thread.task.status}}{{else if (eq thread.type 'Announcement')}}[{{publisherName}}] @{{thread.createdBy}} posted an Announcement
Description: {{thread.announcement.description}}
Started At: {{formatDate thread.announcement.startTime}}
Ends At: {{formatDate thread.announcement.endTime}}{{/if}}{{#if (eq channel 'email')}}

Happy Exploring!
Thanks.{{/if}}" +} \ No newline at end of file diff --git a/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-thread-updated.json b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-thread-updated.json new file mode 100644 index 00000000000..afcb2d99d9b --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/notificationTemplate/system-notification-thread-updated.json @@ -0,0 +1,9 @@ +{ + "name": "system-notification-thread-updated", + "fullyQualifiedName": "system-notification-thread-updated", + "displayName": "Thread Updated Notification", + "description": "System default template for thread update notifications", + "provider": "system", + "templateSubject": "{{thread.type}} updated - {{entity.fullyQualifiedName}}", + "templateBody": "{{#if (eq channel 'email')}}Hello {{recipientName}},

{{/if}}{{#if (eq thread.type 'Task')}}[{{publisherName}}] @{{thread.updatedBy}} posted update on the Task with Id: {{thread.task.id}} for Asset {{entity.fullyQualifiedName}}
Task Type: {{thread.task.type}}
Assignees: {{#each thread.task.assignees}}@{{name}}{{#unless @last}}, {{/unless}}{{/each}}
Current Status: {{thread.task.status}}{{else if (eq thread.type 'Announcement')}}[{{publisherName}}] @{{thread.updatedBy}} posted an update on Announcement{{#if (eq event.eventType 'ENTITY_DELETED')}}
Announcement Deleted: {{thread.announcement.description}}{{else}}
Description: {{thread.announcement.description}}
Started At: {{formatDate thread.announcement.startTime}}
Ends At: {{formatDate thread.announcement.endTime}}{{/if}}{{else}}[{{publisherName}}] @{{thread.updatedBy}} posted update on Conversation for asset {{entity.fullyQualifiedName}}
{{thread.message}}{{/if}}{{#if (eq channel 'email')}}

Happy Exploring!
Thanks.{{/if}}" +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 2ac3c7af98d..582864cf938 100644 --- a/pom.xml +++ b/pom.xml @@ -117,7 +117,7 @@ 2.21.0 5.9.3 4.0.14 - 4.3.1 + 4.5.0 1.5.0 4.13.2