Fixes #23129: Add system-default notification templates (#23441)

* Setup system provided templates for ChangeEvent notifications

* Setup handlebars helpers for system-default notification templates
This commit is contained in:
Adrià Manero 2025-09-24 09:56:30 +02:00 committed by GitHub
parent 8deab28071
commit dacd60d535
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1054 additions and 1 deletions

View File

@ -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);
}

View File

@ -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<HandlebarsHelper> 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());
}
}

View File

@ -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<String, Object> refMap = (Map<String, Object>) entityRef;
Optional<String> typeOpt =
Optional.ofNullable(refMap.get(KEY_TYPE))
.map(Object::toString)
.map(String::trim)
.filter(s -> !s.isEmpty());
Optional<String> 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();
});
}
}

View File

@ -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}}
*
* <p>Shows differences like: "This is <s>old</s> <b>new</b> text"
*/
public class DiffHelper implements HandlebarsHelper {
private static final String INSERT_OPEN = "<b>";
private static final String INSERT_CLOSE = "</b> ";
private static final String DELETE_OPEN = "<s>";
private static final String DELETE_CLOSE = "</s> ";
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 <b> tags, deletions with <s> 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<DiffMatchPatch.Diff> 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();
}
}

View File

@ -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<String>)
(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());
});
}
}

View File

@ -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);
});
}
}

View File

@ -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}}
*
* <p>Formats column definitions as: columnName (type, constraints)
*/
public class FormatColumnValueHelper implements HandlebarsHelper {
private static final String EMPTY_VALUE = "<em>empty</em>";
@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();
}
}

View File

@ -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}}
*
* <p>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<SimpleDateFormat> 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;
}
}
}

View File

@ -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>(T value, int index) {}
private record MatchResult(List<FieldChange> mergedUpdates, BitSet matchedDeleteIdx) {}
@Override
public String getName() {
return "groupEventChanges";
}
@Override
public void register(Handlebars handlebars) {
handlebars.registerHelper(
getName(), (Helper<ChangeDescription>) (changeDesc, options) -> groupChanges(changeDesc));
}
private ChangeGroups groupChanges(ChangeDescription changeDesc) {
// Extract all field changes from the change description
final List<FieldChange> fieldsUpdated = extractFieldsUpdated(changeDesc);
final List<FieldChange> fieldsAdded = extractFieldsAdded(changeDesc);
final List<FieldChange> fieldsDeleted = extractFieldsDeleted(changeDesc);
// Build the initial update list from direct updates
final List<FieldChange> updates = new ArrayList<>(buildDirectUpdates(fieldsUpdated));
// Build indexed queues for efficient lookup of adds by field name
final Map<String, Deque<Indexed<FieldChange>>> 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<FieldChange> unmatchedDeletes =
collectUnmatchedDeletes(fieldsDeleted, matchResult.matchedDeleteIdx());
// Collect remaining adds that weren't consumed
final List<FieldChange> remainingAdds = collectRemainingAdds(fieldsAdded, consumedAddedIdx);
// Combine all updates (direct + merged)
updates.addAll(matchResult.mergedUpdates());
return new ChangeGroups(updates, remainingAdds, unmatchedDeletes);
}
private List<FieldChange> buildDirectUpdates(List<FieldChange> fieldsUpdated) {
return fieldsUpdated.stream()
.map(
u ->
new FieldChange()
.withName(u.getName())
.withOldValue(u.getOldValue())
.withNewValue(u.getNewValue()))
.toList();
}
private MatchResult matchDeletesWithAdds(
List<FieldChange> fieldsDeleted,
Map<String, Deque<Indexed<FieldChange>>> addedQueues,
BitSet consumedAddedIdx) {
final List<FieldChange> 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<Indexed<FieldChange>> queue = addedQueues.get(delete.getName());
if (queue != null && !queue.isEmpty()) {
// Found matching add - consume it and create merged update
final Indexed<FieldChange> 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<FieldChange> collectUnmatchedDeletes(
List<FieldChange> fieldsDeleted, BitSet matchedDeleteIdx) {
final List<FieldChange> 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<FieldChange> collectRemainingAdds(
List<FieldChange> fieldsAdded, BitSet consumedAddedIdx) {
final List<FieldChange> 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<Indexed<FieldChange>> queue, FieldChange matchedAdd, BitSet consumedAddedIdx) {
queue.removeIf(
ix -> {
if (Objects.equals(ix.value(), matchedAdd)) {
consumedAddedIdx.set(ix.index());
return true;
}
return false;
});
}
private List<FieldChange> extractFieldsUpdated(ChangeDescription changeDesc) {
return Optional.ofNullable(changeDesc)
.map(ChangeDescription::getFieldsUpdated)
.orElse(List.of());
}
private List<FieldChange> extractFieldsAdded(ChangeDescription changeDesc) {
return Optional.ofNullable(changeDesc).map(ChangeDescription::getFieldsAdded).orElse(List.of());
}
private List<FieldChange> extractFieldsDeleted(ChangeDescription changeDesc) {
return Optional.ofNullable(changeDesc)
.map(ChangeDescription::getFieldsDeleted)
.orElse(List.of());
}
private Map<String, Deque<Indexed<FieldChange>>> buildAddedQueues(List<FieldChange> fieldsAdded) {
final Map<String, Deque<Indexed<FieldChange>>> 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<FieldChange> updates, List<FieldChange> adds, List<FieldChange> deletes) {}
}

View File

@ -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}}
*
* <p>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();
};
}
}

View File

@ -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;
}
}

View File

@ -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}}
*
* <p>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;
}
}
}

View File

@ -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));
});
}
}

View File

@ -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);
});
}
}

View File

@ -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}},<br/><br/>{{/if}}[{{publisherName}}] <b>{{event.userName}}</b> created {{entity.entityType}} <a href=\"{{entity.href}}\">{{entity.fullyQualifiedName}}</a>{{#if entity.description}}<br/>Description: {{entity.description}}{{/if}}{{#if (eq channel 'email')}}<br/><br/>Happy Exploring!<br/>Thanks.{{/if}}"
}

View File

@ -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}},<br/><br/>{{/if}}[{{publisherName}}] <b>{{event.userName}}</b> performed {{event.eventType}} on <a href=\"{{entity.href}}\">{{entity.fullyQualifiedName}}</a>{{#if changes.fieldsUpdated}}<br/><b>Changed:</b> {{#each changes.fieldsUpdated}}{{name}}{{#unless @last}}, {{/unless}}{{/each}}{{/if}}{{#if (eq channel 'email')}}<br/><br/>Happy Exploring!<br/>Thanks.{{/if}}"
}

View File

@ -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}},<br/><br/>{{/if}}[{{publisherName}}] <b>{{event.userName}}</b> deleted {{entity.entityType}} {{entity.fullyQualifiedName}}{{#if (eq channel 'email')}}<br/><br/>Happy Exploring!<br/>Thanks.{{/if}}"
}

View File

@ -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}},<br/><br/>{{/if}}[{{publisherName}}] <b>{{event.userName}}</b> soft deleted {{entity.entityType}} <a href=\"{{entity.href}}\">{{entity.fullyQualifiedName}}</a>{{#if (eq channel 'email')}}<br/><br/>Happy Exploring!<br/>Thanks.{{/if}}"
}

View File

@ -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}},<br/><br/>{{/if}}[{{publisherName}}] <b>{{event.userName}}</b> added logical test cases to <a href=\"{{entity.href}}\">{{entity.fullyQualifiedName}}</a>{{#if (eq channel 'email')}}<br/><br/>Happy Exploring!<br/>Thanks.{{/if}}"
}

View File

@ -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}},<br/><br/>{{/if}}{{#if (eq thread.type 'Task')}}[{{publisherName}}] <b>@{{thread.createdBy}}</b> posted a message on the Task with Id: {{thread.task.id}} for Asset <a href=\"{{entity.href}}\">{{entity.fullyQualifiedName}}</a>{{#each thread.posts}}<br/><b>@{{from}}:</b> {{message}}{{/each}}{{else if (eq thread.type 'Announcement')}}[{{publisherName}}] <b>@{{thread.createdBy}}</b> posted a message on Announcement{{#each thread.posts}}<br/><b>@{{from}}:</b> {{message}}{{/each}}{{else}}[{{publisherName}}] <b>@{{thread.createdBy}}</b> posted a message on asset <a href=\"{{entity.href}}\">{{entity.fullyQualifiedName}}</a>{{#each thread.posts}}<br/><b>@{{from}}:</b> {{message}}{{/each}}{{/if}}{{#if (eq channel 'email')}}<br/><br/>Happy Exploring!<br/>Thanks.{{/if}}"
}

View File

@ -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}},<br/><br/>{{/if}}[{{publisherName}}] <b>@{{thread.createdBy}}</b> closed Task with Id: {{thread.task.id}} for Asset <a href=\"{{entity.href}}\">{{entity.fullyQualifiedName}}</a><br/><b>Current Status:</b> Closed{{#if (eq channel 'email')}}<br/><br/>Happy Exploring!<br/>Thanks.{{/if}}"
}

View File

@ -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}},<br/><br/>{{/if}}[{{publisherName}}] <b>@{{thread.createdBy}}</b> resolved Task with Id: {{thread.task.id}} for Asset <a href=\"{{entity.href}}\">{{entity.fullyQualifiedName}}</a><br/><b>Current Status:</b> Resolved{{#if (eq channel 'email')}}<br/><br/>Happy Exploring!<br/>Thanks.{{/if}}"
}

View File

@ -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}},<br/><br/>{{/if}}<b>Test Result Update</b><br/>Test Case: <a href=\"{{entity.href}}\">{{entity.fullyQualifiedName}}</a><br/><b>Status:</b> {{#if (eq testResult.testCaseStatus 'Success')}}<span style=\"color:#48CA9E\">✓ Passed</span>{{else if (eq testResult.testCaseStatus 'Failed')}}<span style=\"color:#F24822\">✗ Failed</span>{{else if (eq testResult.testCaseStatus 'Aborted')}}<span style=\"color:#FFBE0E\">⚠ Aborted</span>{{else}}<span style=\"color:#959595\">⏳ {{testResult.testCaseStatus}}</span>{{/if}}{{#if testResult.result}}<br/><b>Result:</b> {{testResult.result}}{{/if}}{{#if testResult.testResultValue}}<br/><b>Value:</b> {{testResult.testResultValue}}{{/if}}<br/><b>Timestamp:</b> {{formatDate testResult.timestamp}}{{#if entity.description}}<br/><b>Test Description:</b> {{entity.description}}{{/if}}{{#if (eq channel 'email')}}<br/><br/>Happy Exploring!<br/>Thanks.{{/if}}"
}

View File

@ -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}},<br/><br/>{{/if}}{{#if (eq thread.type 'Conversation')}}[{{publisherName}}] <b>@{{thread.createdBy}}</b> started a conversation for asset <a href=\"{{entity.href}}\">{{entity.fullyQualifiedName}}</a><br/>{{thread.message}}{{else if (eq thread.type 'Task')}}[{{publisherName}}] <b>@{{thread.createdBy}}</b> created a Task for {{entity.entityType}} <a href=\"{{entity.href}}\">{{entity.fullyQualifiedName}}</a><br/><b>Task Type:</b> {{thread.task.type}}<br/><b>Assignees:</b> {{#each thread.task.assignees}}@{{name}}{{#unless @last}}, {{/unless}}{{/each}}<br/><b>Current Status:</b> {{thread.task.status}}{{else if (eq thread.type 'Announcement')}}[{{publisherName}}] <b>@{{thread.createdBy}}</b> posted an Announcement<br/><b>Description:</b> {{thread.announcement.description}}<br/><b>Started At:</b> {{formatDate thread.announcement.startTime}}<br/><b>Ends At:</b> {{formatDate thread.announcement.endTime}}{{/if}}{{#if (eq channel 'email')}}<br/><br/>Happy Exploring!<br/>Thanks.{{/if}}"
}

View File

@ -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}},<br/><br/>{{/if}}{{#if (eq thread.type 'Task')}}[{{publisherName}}] <b>@{{thread.updatedBy}}</b> posted update on the Task with Id: {{thread.task.id}} for Asset <a href=\"{{entity.href}}\">{{entity.fullyQualifiedName}}</a><br/><b>Task Type:</b> {{thread.task.type}}<br/><b>Assignees:</b> {{#each thread.task.assignees}}@{{name}}{{#unless @last}}, {{/unless}}{{/each}}<br/><b>Current Status:</b> {{thread.task.status}}{{else if (eq thread.type 'Announcement')}}[{{publisherName}}] <b>@{{thread.updatedBy}}</b> posted an update on Announcement{{#if (eq event.eventType 'ENTITY_DELETED')}}<br/><b>Announcement Deleted:</b> {{thread.announcement.description}}{{else}}<br/><b>Description:</b> {{thread.announcement.description}}<br/><b>Started At:</b> {{formatDate thread.announcement.startTime}}<br/><b>Ends At:</b> {{formatDate thread.announcement.endTime}}{{/if}}{{else}}[{{publisherName}}] <b>@{{thread.updatedBy}}</b> posted update on Conversation for asset <a href=\"{{entity.href}}\">{{entity.fullyQualifiedName}}</a><br/>{{thread.message}}{{/if}}{{#if (eq channel 'email')}}<br/><br/>Happy Exploring!<br/>Thanks.{{/if}}"
}

View File

@ -117,7 +117,7 @@
<log4j.version>2.21.0</log4j.version>
<org.junit.jupiter.version>5.9.3</org.junit.jupiter.version>
<dropwizard-health.version>4.0.14</dropwizard-health.version>
<handlebars.version>4.3.1</handlebars.version>
<handlebars.version>4.5.0</handlebars.version>
<fernet.version>1.5.0</fernet.version>
<antlr.version>4.13.2</antlr.version>