Many resources are needed to download a project. Please understand that we have to compensate our server costs. Thank you in advance. Project price only 1 $
You can buy this project and download/modify it how often you want.
/*
* 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.jdbi3;
import static org.openmetadata.common.utils.CommonUtil.listOrEmpty;
import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty;
import static org.openmetadata.schema.type.EventType.ENTITY_DELETED;
import static org.openmetadata.schema.type.EventType.ENTITY_NO_CHANGE;
import static org.openmetadata.schema.type.EventType.POST_UPDATED;
import static org.openmetadata.schema.type.EventType.TASK_CLOSED;
import static org.openmetadata.schema.type.EventType.TASK_RESOLVED;
import static org.openmetadata.schema.type.EventType.THREAD_UPDATED;
import static org.openmetadata.schema.type.Include.ALL;
import static org.openmetadata.schema.type.Include.NON_DELETED;
import static org.openmetadata.schema.type.Relationship.ADDRESSED_TO;
import static org.openmetadata.schema.type.Relationship.CREATED;
import static org.openmetadata.schema.type.Relationship.IS_ABOUT;
import static org.openmetadata.schema.type.Relationship.MENTIONED_IN;
import static org.openmetadata.schema.type.Relationship.REPLIED_TO;
import static org.openmetadata.schema.type.TaskStatus.Open;
import static org.openmetadata.service.Entity.GLOSSARY;
import static org.openmetadata.service.Entity.GLOSSARY_TERM;
import static org.openmetadata.service.Entity.USER;
import static org.openmetadata.service.exception.CatalogExceptionMessage.ANNOUNCEMENT_INVALID_START_TIME;
import static org.openmetadata.service.exception.CatalogExceptionMessage.ANNOUNCEMENT_OVERLAP;
import static org.openmetadata.service.exception.CatalogExceptionMessage.entityNotFound;
import static org.openmetadata.service.jdbi3.UserRepository.TEAMS_FIELD;
import static org.openmetadata.service.util.EntityUtil.compareEntityReference;
import io.jsonwebtoken.lang.Collections;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.json.JsonPatch;
import javax.ws.rs.core.Response.Status;
import javax.ws.rs.core.SecurityContext;
import javax.ws.rs.core.UriInfo;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Triple;
import org.jdbi.v3.sqlobject.transaction.Transaction;
import org.json.JSONObject;
import org.openmetadata.schema.EntityInterface;
import org.openmetadata.schema.api.feed.CloseTask;
import org.openmetadata.schema.api.feed.ResolveTask;
import org.openmetadata.schema.api.feed.ThreadCount;
import org.openmetadata.schema.entity.feed.Thread;
import org.openmetadata.schema.entity.teams.User;
import org.openmetadata.schema.type.ChangeEvent;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.EventType;
import org.openmetadata.schema.type.Include;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.schema.type.Post;
import org.openmetadata.schema.type.Reaction;
import org.openmetadata.schema.type.Relationship;
import org.openmetadata.schema.type.TagLabel;
import org.openmetadata.schema.type.TaskDetails;
import org.openmetadata.schema.type.TaskStatus;
import org.openmetadata.schema.type.TaskType;
import org.openmetadata.schema.type.ThreadType;
import org.openmetadata.schema.utils.EntityInterfaceUtil;
import org.openmetadata.service.Entity;
import org.openmetadata.service.ResourceRegistry;
import org.openmetadata.service.exception.CatalogExceptionMessage;
import org.openmetadata.service.exception.EntityNotFoundException;
import org.openmetadata.service.formatter.decorators.FeedMessageDecorator;
import org.openmetadata.service.formatter.decorators.MessageDecorator;
import org.openmetadata.service.formatter.util.FeedMessage;
import org.openmetadata.service.resources.feeds.FeedResource;
import org.openmetadata.service.resources.feeds.FeedUtil;
import org.openmetadata.service.resources.feeds.MessageParser;
import org.openmetadata.service.resources.feeds.MessageParser.EntityLink;
import org.openmetadata.service.security.AuthorizationException;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.security.policyevaluator.ResourceContext;
import org.openmetadata.service.util.EntityUtil;
import org.openmetadata.service.util.FullyQualifiedName;
import org.openmetadata.service.util.JsonUtils;
import org.openmetadata.service.util.RestUtil.DeleteResponse;
import org.openmetadata.service.util.RestUtil.PatchResponse;
import org.openmetadata.service.util.ResultList;
/*
* Feed relationships:
* - 'user' --- createdBy ---> 'thread' in entity_relationship
* - 'user' --- repliedTo ---> 'thread' in entity_relationship
* - 'user' --- mentionedIn ---> 'thread' in entity_relationship
* - 'user' --- reactedTo ---> 'thread' in entity_relationship
* - 'thread' --- addressedTo ---> 'user' in field_relationship
* - 'thread' --- isAbout ---> 'entity' in entity_relationship
*/
@Slf4j
@Repository
public class FeedRepository {
public static final String DELETED_USER_NAME = "DeletedUser";
public static final String DELETED_USER_DISPLAY = "User was deleted";
public static final String DELETED_TEAM_NAME = "DeletedTeam";
public static final String DELETED_TEAM_DISPLAY = "Team was deleted";
private static final long MAX_SECONDS_TIMESTAMP = 2147483647L;
private final CollectionDAO dao;
private static final MessageDecorator FEED_MESSAGE_FORMATTER =
new FeedMessageDecorator();
public FeedRepository() {
this.dao = Entity.getCollectionDAO();
Entity.setFeedRepository(this);
ResourceRegistry.addResource("feed", null, Entity.getEntityFields(Thread.class));
}
public enum FilterType {
OWNER,
MENTIONS,
FOLLOWS,
ASSIGNED_TO,
ASSIGNED_BY,
OWNER_OR_FOLLOWS
}
public enum PaginationType {
BEFORE,
AFTER
}
public int getNextTaskId() {
dao.feedDAO().updateTaskId();
return dao.feedDAO().getTaskId();
}
@Getter
public static class ThreadContext {
protected final Thread thread;
@Setter protected EntityLink about;
@Setter protected EntityInterface aboutEntity;
private final EntityReference createdBy;
ThreadContext(Thread thread) {
this.thread = thread;
this.about = EntityLink.parse(thread.getAbout());
this.aboutEntity = Entity.getEntity(about, getFields(), ALL);
this.createdBy =
Entity.getEntityReferenceByName(Entity.USER, thread.getCreatedBy(), NON_DELETED);
thread.withEntityRef(aboutEntity.getEntityReference()); // Add entity id to thread
}
ThreadContext(Thread thread, ChangeEvent event) {
this.thread = thread;
this.about = EntityLink.parse(thread.getAbout());
if (event.getEventType().equals(ENTITY_DELETED)) {
String json = (String) event.getEntity();
this.aboutEntity =
JsonUtils.readValue(json, Entity.getEntityClassFromType(event.getEntityType()));
} else {
this.aboutEntity = Entity.getEntity(about, getFields(), ALL);
}
this.createdBy =
Entity.getEntityReferenceByName(Entity.USER, thread.getCreatedBy(), NON_DELETED);
thread.withEntityRef(aboutEntity.getEntityReference()); // Add entity id to thread
}
public TaskWorkflow getTaskWorkflow() {
EntityRepository> repository = Entity.getEntityRepository(about.getEntityType());
return repository.getTaskWorkflow(this);
}
public EntityRepository extends EntityInterface> getEntityRepository() {
return Entity.getEntityRepository(about.getEntityType());
}
private String getFields() {
EntityRepository> repository = getEntityRepository();
List fieldList = new ArrayList<>();
if (repository.supportsOwners) {
fieldList.add("owners");
}
if (repository.supportsTags) {
fieldList.add("tags");
}
return String.join(",", fieldList.toArray(new String[0]));
}
}
public abstract static class TaskWorkflow {
protected final ThreadContext threadContext;
TaskWorkflow(ThreadContext threadContext) {
this.threadContext = threadContext;
}
public abstract EntityInterface performTask(String user, ResolveTask resolveTask);
@SuppressWarnings("unused")
protected void closeTask(String user, CloseTask closeTask) {}
protected final TaskType getTaskType() {
return threadContext.getThread().getTask().getType();
}
protected final EntityLink getAbout() {
return threadContext.getAbout();
}
}
private ThreadContext getThreadContext(Thread thread) {
return new ThreadContext(thread);
}
private ThreadContext getThreadContext(Thread thread, ChangeEvent event) {
return new ThreadContext(thread, event);
}
@Transaction
public Thread create(Thread thread) {
ThreadContext threadContext = getThreadContext(thread);
return createThread(threadContext);
}
@Transaction
public void create(Thread thread, ChangeEvent event) {
ThreadContext threadContext = getThreadContext(thread, event);
createThread(threadContext);
}
@Transaction
public void store(ThreadContext threadContext) {
// Insert a new thread
dao.feedDAO().insert(JsonUtils.pojoToJson(threadContext.getThread()));
}
@Transaction
public void storeRelationships(ThreadContext threadContext) {
Thread thread = threadContext.getThread();
EntityLink about = threadContext.getAbout();
// Add relationship User -- created --> Thread relationship
dao.relationshipDAO()
.insert(
threadContext.getCreatedBy().getId(),
thread.getId(),
USER,
Entity.THREAD,
CREATED.ordinal());
// Add field relationship for data asset - Thread -- isAbout ---> entity/entityField
dao.fieldRelationshipDAO()
.insert(
thread.getId().toString(), // from FQN
about.getFullyQualifiedFieldValue(), // to FQN,
thread.getId().toString(),
about.getFullyQualifiedFieldValue(),
Entity.THREAD, // From type
about.getFullyQualifiedFieldType(), // to Type
IS_ABOUT.ordinal(),
null);
// Add the owner also as addressedTo as the entity he owns when addressed, the owner is
// actually being addressed
List entityOwners = threadContext.getAboutEntity().getOwners();
if (!nullOrEmpty(entityOwners)) {
for (EntityReference entityOwner : entityOwners) {
dao.relationshipDAO()
.insert(
thread.getId(),
entityOwner.getId(),
Entity.THREAD,
entityOwner.getType(),
ADDRESSED_TO.ordinal());
}
}
// Add mentions to field relationship table
storeMentions(thread, thread.getMessage());
}
public Thread getTask(EntityLink about, TaskType taskType) {
List> tasks =
dao.fieldRelationshipDAO()
.findFrom(
about.getFullyQualifiedFieldValue(),
about.getFullyQualifiedFieldType(),
IS_ABOUT.ordinal());
for (Triple task : tasks) {
if (task.getMiddle().equals(Entity.THREAD)) {
UUID threadId = UUID.fromString(task.getLeft());
Thread thread =
EntityUtil.validate(threadId, dao.feedDAO().findById(threadId), Thread.class);
if (thread.getTask() != null && thread.getTask().getType() == taskType) {
return thread;
}
}
}
throw new EntityNotFoundException(
String.format(
"Task for entity %s of type %s was not found", about.getEntityType(), taskType));
}
private Thread createThread(ThreadContext threadContext) {
Thread thread = threadContext.getThread();
if (thread.getType() == ThreadType.Task) {
validateAssignee(thread);
thread.getTask().withId(getNextTaskId());
} else if (thread.getType() == ThreadType.Announcement) {
// Validate start and end time for announcement
validateAnnouncement(thread);
}
store(threadContext);
storeRelationships(threadContext);
populateAssignees(threadContext.getThread());
return threadContext.getThread();
}
public Thread get(UUID id) {
Thread thread = EntityUtil.validate(id, dao.feedDAO().findById(id), Thread.class);
sortPosts(thread);
return thread;
}
public Thread getTask(Integer id) {
Thread task = EntityUtil.validate(id, dao.feedDAO().findByTaskId(id), Thread.class);
sortPosts(task);
return populateAssignees(task);
}
public PatchResponse closeTask(
UriInfo uriInfo, Thread thread, String user, CloseTask closeTask) {
// Update the attributes
closeTask(thread, user, closeTask);
Thread updatedHref = FeedResource.addHref(uriInfo, thread);
return new PatchResponse<>(Status.OK, updatedHref, TASK_CLOSED);
}
public PatchResponse resolveTask(
UriInfo uriInfo, Thread thread, String user, ResolveTask resolveTask) {
// perform the task
ThreadContext threadContext = getThreadContext(thread);
resolveTask(threadContext, user, resolveTask);
Thread updatedHref = FeedResource.addHref(uriInfo, thread);
return new PatchResponse<>(Status.OK, updatedHref, TASK_RESOLVED);
}
protected void resolveTask(ThreadContext threadContext, String user, ResolveTask resolveTask) {
TaskWorkflow taskWorkflow = threadContext.getTaskWorkflow();
EntityInterface aboutEntity = threadContext.getAboutEntity();
String origJson = JsonUtils.pojoToJson(aboutEntity);
EntityInterface updatedEntity = taskWorkflow.performTask(user, resolveTask);
String updatedEntityJson = JsonUtils.pojoToJson(updatedEntity);
JsonPatch patch = JsonUtils.getJsonPatch(origJson, updatedEntityJson);
EntityRepository> repository = threadContext.getEntityRepository();
repository.patch(null, aboutEntity.getId(), user, patch);
// Update the attributes
threadContext.getThread().getTask().withNewValue(resolveTask.getNewValue());
closeTask(threadContext.getThread(), user, new CloseTask());
}
private static String getTagFQNs(List tags) {
return tags.stream().map(TagLabel::getTagFQN).collect(Collectors.joining(", "));
}
@Transaction
private void addClosingPost(Thread thread, String user, String closingComment) {
// Add a post to the task
String message;
if (closingComment != null) {
message = closeTaskMessage(closingComment);
} else {
// The task was resolved with an update.
// Add a default message to the Task thread with updated description/tag
TaskDetails task = thread.getTask();
TaskType type = task.getType();
if (EntityUtil.isDescriptionTask(type)) {
message = resolveDescriptionTaskMessage(task);
} else if (EntityUtil.isTagTask(type)) {
message = resolveTagTaskMessage(task);
} else {
message = "Resolved the Task.";
}
}
Post post =
new Post()
.withId(UUID.randomUUID())
.withMessage(message)
.withFrom(user)
.withReactions(java.util.Collections.emptyList())
.withPostTs(System.currentTimeMillis());
addPostToThread(thread.getId(), post, user);
}
@Transaction
public void closeTask(Thread thread, String user, CloseTask closeTask) {
ThreadContext threadContext = getThreadContext(thread);
TaskDetails task = thread.getTask();
if (task.getStatus() != Open) {
return;
}
TaskWorkflow workflow = threadContext.getTaskWorkflow();
workflow.closeTask(user, closeTask);
task.withStatus(TaskStatus.Closed).withClosedBy(user).withClosedAt(System.currentTimeMillis());
thread.withTask(task).withUpdatedBy(user).withUpdatedAt(System.currentTimeMillis());
dao.feedDAO().update(thread.getId(), JsonUtils.pojoToJson(thread));
addClosingPost(thread, user, closeTask.getComment());
sortPosts(thread);
}
@Transaction
public void closeTaskWithoutWorkflow(Thread thread, String user, CloseTask closeTask) {
TaskDetails task = thread.getTask();
if (task.getStatus() != Open) {
return;
}
task.withStatus(TaskStatus.Closed).withClosedBy(user).withClosedAt(System.currentTimeMillis());
thread.withTask(task).withUpdatedBy(user).withUpdatedAt(System.currentTimeMillis());
dao.feedDAO().update(thread.getId(), JsonUtils.pojoToJson(thread));
addClosingPost(thread, user, closeTask.getComment());
sortPosts(thread);
}
private void storeMentions(Thread thread, String message) {
// Create relationship for users, teams, and other entities that are mentioned in the post
// Multiple mentions of the same entity is handled by taking distinct mentions
List mentions = MessageParser.getEntityLinks(message);
mentions.stream()
.distinct()
.forEach(
mention ->
dao.fieldRelationshipDAO()
.insert(
mention.getFullyQualifiedFieldValue(),
thread.getId().toString(),
mention.getFullyQualifiedFieldValue(),
thread.getId().toString(),
mention.getFullyQualifiedFieldType(),
Entity.THREAD,
Relationship.MENTIONED_IN.ordinal(),
null));
}
@Transaction
public Thread addPostToThread(UUID id, Post post, String userName) {
// Validate the user posting the message
UUID fromUserId = Entity.getEntityReferenceByName(USER, post.getFrom(), NON_DELETED).getId();
// Update the thread with the new post
Thread thread = EntityUtil.validate(id, dao.feedDAO().findById(id), Thread.class);
// Populate Assignees if type is task
populateAssignees(thread);
thread.withUpdatedBy(userName).withUpdatedAt(System.currentTimeMillis());
FeedUtil.addPost(thread, post);
dao.feedDAO().update(id, JsonUtils.pojoToJson(thread));
// Add relation User -- repliedTo --> Thread
// Add relationship from thread to the user entity that is posting a reply
boolean relationAlreadyExists =
thread.getPosts().stream().anyMatch(p -> p.getFrom().equals(post.getFrom()));
if (!relationAlreadyExists) {
dao.relationshipDAO()
.insert(fromUserId, thread.getId(), USER, Entity.THREAD, REPLIED_TO.ordinal());
}
// Add mentions into field relationship table
storeMentions(thread, post.getMessage());
sortPostsInThreads(List.of(thread));
return thread;
}
public Post getPostById(Thread thread, UUID postId) {
Optional post =
thread.getPosts().stream().filter(p -> p.getId().equals(postId)).findAny();
if (post.isEmpty()) {
throw EntityNotFoundException.byMessage(entityNotFound("Post", postId));
}
return post.get();
}
@Transaction
public DeleteResponse deletePost(Thread thread, Post post, String userName) {
List posts = thread.getPosts();
// Remove the post to be deleted from the posts list
posts = posts.stream().filter(p -> !p.getId().equals(post.getId())).toList();
thread
.withUpdatedAt(System.currentTimeMillis())
.withUpdatedBy(userName)
.withPosts(posts)
.withPostsCount(posts.size());
// update the json document
dao.feedDAO().update(thread.getId(), JsonUtils.pojoToJson(thread));
return new DeleteResponse<>(post, ENTITY_DELETED);
}
@Transaction
public DeleteResponse deleteThread(Thread thread, String deletedByUser) {
deleteThreadInternal(thread.getId());
LOG.debug("{} deleted thread with id {}", deletedByUser, thread.getId());
return new DeleteResponse<>(thread, ENTITY_DELETED);
}
@Transaction
public void deleteThreadInternal(UUID id) {
// Delete all the relationships to other entities
dao.relationshipDAO().deleteAll(id, Entity.THREAD);
// Delete all the field relationships to other entities
dao.fieldRelationshipDAO().deleteAllByPrefix(id.toString());
// Finally, delete the thread
dao.feedDAO().delete(id);
}
@Transaction
public void deleteByAbout(UUID entityId) {
List threadIds = listOrEmpty(dao.feedDAO().findByEntityId(entityId.toString()));
for (String threadId : threadIds) {
try {
deleteThreadInternal(UUID.fromString(threadId));
} catch (Exception ex) {
// Continue deletion
}
}
}
public List getThreadsCount(String link) {
List> result;
EntityLink entityLink = EntityLink.parse(link);
List threadCounts = new ArrayList<>();
EntityReference reference = EntityUtil.validateEntityLink(entityLink);
int mentions;
if (reference.getType().equals(USER) || reference.getType().equals(Entity.TEAM)) {
if (reference.getType().equals(USER)) {
UUID userId = reference.getId();
User user = Entity.getEntity(USER, userId, TEAMS_FIELD, ALL);
List teamIds = getTeamIds(user);
List teamNames = getTeamNames(user);
String userTeamJsonMysql = getUserTeamJsonMysql(userId, teamIds);
String userTeamJsonPostgres = getUserTeamJsonPostgres(userId, teamIds);
result =
dao.feedDAO()
.listCountByOwner(
userId, teamIds, user.getName(), userTeamJsonMysql, userTeamJsonPostgres);
mentions =
dao.feedDAO()
.listCountThreadsByMentions(
FullyQualifiedName.buildHash(user.getFullyQualifiedName()),
teamNames,
Relationship.MENTIONED_IN.ordinal(),
" where true ");
} else {
mentions = 0;
// team is not supported
result = new ArrayList<>();
}
ThreadCount threadCount = new ThreadCount().withMentionCount(mentions);
threadCount.setEntityLink(link);
result.forEach(
l -> {
String type = l.get(0);
String taskStatus = l.get(1);
int count = Integer.parseInt(l.get(2));
if (type.equalsIgnoreCase("Conversation")) {
threadCount.setConversationCount(count);
} else if (type.equalsIgnoreCase("Task")) {
if (taskStatus.equals("Open")) {
threadCount.setOpenTaskCount(count);
} else if (taskStatus.equals("Closed")) {
threadCount.setClosedTaskCount(count);
}
}
});
computeTotalTaskCount(threadCount);
threadCounts.add(threadCount);
} else if (reference.getType().equals(GLOSSARY)) {
mentions = 0;
result = dao.feedDAO().listCountThreadsByGlossaryAndTerms(entityLink, reference);
result.forEach(
l -> {
ThreadCount threadCount = new ThreadCount().withMentionCount(mentions);
String eLink = l.get(0);
String type = l.get(1);
String taskStatus = l.get(2);
threadCount.setEntityLink(eLink);
int count = Integer.parseInt(l.get(3));
if (type.equalsIgnoreCase("Conversation")) {
threadCount.setConversationCount(count);
} else if (type.equalsIgnoreCase("Task")) {
if (taskStatus.equals("Open")) {
threadCount.setOpenTaskCount(count);
} else if (taskStatus.equals("Closed")) {
threadCount.setClosedTaskCount(count);
}
}
computeTotalTaskCount(threadCount);
threadCounts.add(threadCount);
});
} else {
mentions = 0;
result =
dao.feedDAO()
.listCountByEntityLink(
reference.getId(),
reference.getFullyQualifiedName(),
entityLink.getFullyQualifiedFieldType());
result.forEach(
l -> {
ThreadCount threadCount = new ThreadCount().withMentionCount(mentions);
String eLink = l.get(0);
String type = l.get(1);
String taskStatus = l.get(2);
threadCount.setEntityLink(eLink);
int count = Integer.parseInt(l.get(3));
if (type.equalsIgnoreCase("Conversation")) {
threadCount.setConversationCount(count);
} else if (type.equalsIgnoreCase("Task")) {
if (taskStatus.equals("Open")) {
threadCount.setOpenTaskCount(count);
} else if (taskStatus.equals("Closed")) {
threadCount.setClosedTaskCount(count);
}
}
computeTotalTaskCount(threadCount);
threadCounts.add(threadCount);
});
}
return threadCounts;
}
private void computeTotalTaskCount(ThreadCount threadCount) {
threadCount.setTotalTaskCount(
(threadCount.getOpenTaskCount() != null ? threadCount.getOpenTaskCount() : 0)
+ (threadCount.getClosedTaskCount() != null ? threadCount.getClosedTaskCount() : 0));
}
public List listPosts(UUID threadId) {
return get(threadId).getPosts();
}
/** List threads based on the filters and limits in the order of the updated timestamp. */
public ResultList list(
FeedFilter filter, String link, int limitPosts, UUID userId, int limit) {
int total;
List threads;
// No filters are enabled. Listing all the threads
if (link == null && userId == null) {
// Get one extra result used for computing before cursor
List jsons = dao.feedDAO().list(limit + 1, filter.getCondition());
threads = JsonUtils.readObjects(jsons, Thread.class);
total = dao.feedDAO().listCount(filter.getCondition());
} else {
// Either one or both the filters are enabled. We don't support both the filters together.
// If both are not null, entity link takes precedence
if (link != null) {
EntityLink entityLink = EntityLink.parse(link);
EntityReference reference = EntityUtil.validateEntityLink(entityLink);
// For a user entityLink get created or replied relationships to the thread
if (reference.getType().equals(USER)) {
FilteredThreads filteredThreads = getThreadsByOwner(filter, reference.getId(), limit + 1);
threads = filteredThreads.threads();
total = filteredThreads.totalCount();
} else if (reference.getType().equals(GLOSSARY)
&& ThreadType.Task.equals(filter.getThreadType())) {
// Get tasks associated with the glossary term and glossary at glossary level
FilteredThreads filteredThreads =
getThreadsForGlossary(filter, userId, limit + 1, entityLink);
threads = filteredThreads.threads();
total = filteredThreads.totalCount();
} else {
// Only data assets are added as about
User user = userId != null ? Entity.getEntity(USER, userId, TEAMS_FIELD, ALL) : null;
List teamNameHash = getTeamNames(user);
String userName = user == null ? null : user.getFullyQualifiedName();
List jsons =
dao.feedDAO()
.listThreadsByEntityLink(
filter, entityLink, limit + 1, IS_ABOUT.ordinal(), userName, teamNameHash);
threads = JsonUtils.readObjects(jsons, Thread.class);
total =
dao.feedDAO()
.listCountThreadsByEntityLink(
filter, entityLink, IS_ABOUT.ordinal(), userName, teamNameHash);
}
} else {
// userId filter present
FilteredThreads filteredThreads;
if (ThreadType.Task.equals(filter.getThreadType())) {
// Only two filter types are supported for tasks -> ASSIGNED_TO, ASSIGNED_BY
if (FilterType.ASSIGNED_BY.equals(filter.getFilterType())) {
filteredThreads = getTasksAssignedBy(filter, userId, limit + 1);
} else if (FilterType.ASSIGNED_TO.equals(filter.getFilterType())) {
filteredThreads = getTasksAssignedTo(filter, userId, limit + 1);
} else {
// Get all the tasks assigned to or created by the user
filteredThreads = getTasksOfUser(filter, userId, limit + 1);
}
} else {
if (FilterType.FOLLOWS.equals(filter.getFilterType())) {
filteredThreads = getThreadsByFollows(filter, userId, limit + 1);
} else if (FilterType.MENTIONS.equals(filter.getFilterType())) {
filteredThreads = getThreadsByMentions(filter, userId, limit + 1);
} else if (FilterType.OWNER_OR_FOLLOWS.equals(filter.getFilterType())) {
filteredThreads = getThreadsByOwnerOrFollows(filter, userId, limit + 1);
} else {
filteredThreads = getThreadsByOwner(filter, userId, limit + 1);
}
}
threads = filteredThreads.threads();
total = filteredThreads.totalCount();
}
}
sortAndLimitPosts(threads, limitPosts);
populateAssignees(threads);
String beforeCursor = null;
String afterCursor = null;
if (filter.getPaginationType() == PaginationType.BEFORE) {
if (threads.size()
> limit) { // If extra result exists, then previous page exists - return before cursor
threads.remove(0);
beforeCursor = threads.get(0).getUpdatedAt().toString();
}
afterCursor = threads.get(threads.size() - 1).getUpdatedAt().toString();
} else {
beforeCursor = filter.getAfter() == null ? null : threads.get(0).getUpdatedAt().toString();
if (threads.size()
> limit) { // If extra result exists, then next page exists - return after cursor
threads.remove(limit);
afterCursor = threads.get(limit - 1).getUpdatedAt().toString();
}
}
return new ResultList<>(threads, beforeCursor, afterCursor, total);
}
@Transaction
private void storeReactions(Thread thread, String user) {
// Reactions are captured at the thread level. If the user reacted to a post of a thread,
// it will still be tracked as "user reacted to thread" since this will only be used to filter
// threads in the activity feed. Actual reactions are stored in thread.json or post.json itself.
// Multiple reactions by the same user on same thread or post is handled by
// field relationship table constraint (primary key)
dao.fieldRelationshipDAO()
.insert(
EntityInterfaceUtil.quoteName(user),
thread.getId().toString(),
user,
thread.getId().toString(),
USER,
Entity.THREAD,
Relationship.REACTED_TO.ordinal(),
null);
}
public final PatchResponse patchPost(
Thread thread, Post post, String user, JsonPatch patch) {
// Apply JSON patch to the original post to get the updated post
Post updated = JsonUtils.applyPatch(post, patch, Post.class);
restorePatchAttributes(post, updated);
// Update the attributes
populateUserReactions(updated.getReactions());
// delete the existing post and add the updated post
List posts = thread.getPosts();
posts =
posts.stream().filter(p -> !p.getId().equals(post.getId())).collect(Collectors.toList());
posts.add(updated);
thread.withPosts(posts).withUpdatedAt(System.currentTimeMillis()).withUpdatedBy(user);
if (!updated.getReactions().isEmpty()) {
updated
.getReactions()
.forEach(reaction -> storeReactions(thread, reaction.getUser().getName()));
}
sortPosts(thread);
EventType change = patchUpdate(thread, post, updated) ? POST_UPDATED : ENTITY_NO_CHANGE;
return new PatchResponse<>(Status.OK, updated, change);
}
public final PatchResponse patchThread(
UriInfo uriInfo, UUID id, String user, JsonPatch patch) {
// Get all the fields in the original thread that can be updated during PATCH operation
Thread original = get(id);
if (original.getTask() != null) {
List assignees = original.getTask().getAssignees();
populateAssignees(original);
assignees.sort(compareEntityReference);
}
// Apply JSON patch to the original thread to get the updated thread
Thread updated = JsonUtils.applyPatch(original, patch, Thread.class);
// update the "updatedBy" and "updatedAt" fields
updated.withUpdatedAt(System.currentTimeMillis()).withUpdatedBy(user);
restorePatchAttributes(original, updated);
if (!nullOrEmpty(updated.getReactions())) {
populateUserReactions(updated.getReactions());
updated
.getReactions()
.forEach(reaction -> storeReactions(updated, reaction.getUser().getName()));
}
if (updated.getTask() != null) {
populateAssignees(updated);
updated.getTask().getAssignees().sort(compareEntityReference);
validateAssignee(updated);
}
if (updated.getAnnouncement() != null) {
validateAnnouncement(updated);
}
// Update the attributes
EventType change = patchUpdate(original, updated) ? THREAD_UPDATED : ENTITY_NO_CHANGE;
sortPosts(updated);
Thread updatedHref = FeedResource.addHref(uriInfo, updated);
return new PatchResponse<>(Status.OK, updatedHref, change);
}
public void checkPermissionsForResolveTask(
Authorizer authorizer, Thread thread, boolean closeTask, SecurityContext securityContext) {
String userName = securityContext.getUserPrincipal().getName();
User user = Entity.getEntityByName(USER, userName, TEAMS_FIELD, NON_DELETED);
EntityLink about = EntityLink.parse(thread.getAbout());
EntityReference aboutRef = EntityUtil.validateEntityLink(about);
ThreadContext threadContext = getThreadContext(thread);
if (Boolean.TRUE.equals(user.getIsAdmin())) {
return; // Allow admin resolve/close task
}
// Allow if user is an assignee of the resolve/close task
// Allow if user is the owner of the resource for which task is created to resolve/close task
// Allow if user created the task to close task (and not resolve task)
List owners = Entity.getOwners(aboutRef);
List assignees = thread.getTask().getAssignees();
if (!nullOrEmpty(owners)
&& (owners.stream().anyMatch(owner -> owner.getName().equals(userName))
|| closeTask && thread.getCreatedBy().equals(userName))) {
return;
}
// Allow if user is an assignee of the task and if the assignee has permissions to update the
// entity
if (assignees.stream().anyMatch(assignee -> assignee.getName().equals(userName))) {
// If entity does not exist, this is a create operation, else update operation
ResourceContext resourceContext =
new ResourceContext<>(aboutRef.getType(), aboutRef.getId(), null);
if (EntityUtil.isDescriptionTask(threadContext.getTaskWorkflow().getTaskType())) {
OperationContext operationContext =
new OperationContext(aboutRef.getType(), MetadataOperation.EDIT_DESCRIPTION);
authorizer.authorize(securityContext, operationContext, resourceContext);
} else if (EntityUtil.isTagTask(threadContext.getTaskWorkflow().getTaskType())) {
OperationContext operationContext =
new OperationContext(aboutRef.getType(), MetadataOperation.EDIT_TAGS);
authorizer.authorize(securityContext, operationContext, resourceContext);
}
return;
}
// Allow if user belongs to a team that has task assigned to it
// Allow if user belongs to a team if owner of the resource against which task is created
List teams = user.getTeams();
List teamNames = teams.stream().map(EntityReference::getName).toList();
if (assignees.stream().anyMatch(assignee -> teamNames.contains(assignee.getName()))
|| teamNames.stream()
.anyMatch(team -> owners.stream().anyMatch(owner -> team.equals(owner.getName())))) {
return;
}
// Finally, operation is not allowed - throw exception
throw new AuthorizationException(
CatalogExceptionMessage.taskOperationNotAllowed(
userName, closeTask ? "closeTask" : "resolveTask"));
}
private void validateAnnouncement(Thread thread) {
long startTime = thread.getAnnouncement().getStartTime();
long endTime = thread.getAnnouncement().getEndTime();
if (startTime >= endTime) {
throw new IllegalArgumentException(ANNOUNCEMENT_INVALID_START_TIME);
}
// Converts start and end times to milliseconds if they are in seconds.
if (startTime <= MAX_SECONDS_TIMESTAMP && endTime <= MAX_SECONDS_TIMESTAMP) {
convertStartAndEndTimeToMilliseconds(thread);
}
// TODO fix this - overlapping announcements should be allowed
List announcements =
dao.feedDAO()
.listAnnouncementBetween(
thread.getId(), thread.getEntityRef().getId(), startTime, endTime);
if (!announcements.isEmpty()) {
// There is already an announcement that overlaps the new one
throw new IllegalArgumentException(ANNOUNCEMENT_OVERLAP);
}
}
private static void convertStartAndEndTimeToMilliseconds(Thread thread) {
Optional.ofNullable(thread.getAnnouncement())
.ifPresent(
announcement -> {
Optional.ofNullable(announcement.getStartTime())
.ifPresent(
startTime ->
announcement.setStartTime(convertSecondsToMilliseconds(startTime)));
Optional.ofNullable(announcement.getEndTime())
.ifPresent(
endTime -> announcement.setEndTime(convertSecondsToMilliseconds(endTime)));
});
}
private static long convertSecondsToMilliseconds(long seconds) {
return LocalDateTime.ofEpochSecond(seconds, 0, ZoneOffset.UTC)
.toInstant(ZoneOffset.UTC)
.toEpochMilli();
}
private void validateAssignee(Thread thread) {
if (thread != null && ThreadType.Task.equals(thread.getType())) {
String createdByUserName = thread.getCreatedBy();
User createdByUser =
Entity.getEntityByName(USER, createdByUserName, TEAMS_FIELD, NON_DELETED);
if (Boolean.TRUE.equals(createdByUser.getIsBot())) {
throw new IllegalArgumentException("Task cannot be created by bot only by user or teams");
}
List assignees = thread.getTask().getAssignees();
// Assignees can only be user or teams
assignees.forEach(
assignee -> {
if (!assignee.getType().equals(Entity.USER)
&& !assignee.getType().equals(Entity.TEAM)) {
throw new IllegalArgumentException("Assignees can only be user or teams");
}
});
for (EntityReference ref : assignees) {
EntityRepository> repository = Entity.getEntityRepository(ref.getType());
if (ref.getType().equals(USER)) {
User user = (User) repository.get(null, ref.getId(), repository.getFields("id"));
if (Boolean.TRUE.equals(user.getIsBot())) {
throw new IllegalArgumentException("Assignees can not be bot");
}
}
}
}
}
private void restorePatchAttributes(Thread original, Thread updated) {
// Patch can't make changes to following fields. Ignore the changes
updated.withId(original.getId()).withAbout(original.getAbout()).withType(original.getType());
}
private void restorePatchAttributes(Post original, Post updated) {
// Patch can't make changes to following fields. Ignore the changes
updated.withId(original.getId()).withPostTs(original.getPostTs()).withFrom(original.getFrom());
}
private void populateUserReactions(List reactions) {
if (!Collections.isEmpty(reactions)) {
reactions.forEach(
reaction ->
reaction.setUser(
Entity.getEntityReferenceById(USER, reaction.getUser().getId(), Include.ALL)));
}
}
private boolean patchUpdate(Thread original, Thread updated) {
// store the updated thread
// if there is no change, there is no need to apply patch
if (fieldsChanged(original, updated)) {
populateUserReactions(updated.getReactions());
dao.feedDAO().update(updated.getId(), JsonUtils.pojoToJson(updated));
return true;
}
return false;
}
private boolean patchUpdate(Thread thread, Post originalPost, Post updatedPost) {
// store the updated post
// if there is no change, there is no need to apply patch
if (fieldsChanged(originalPost, updatedPost)) {
dao.feedDAO().update(thread.getId(), JsonUtils.pojoToJson(thread));
return true;
}
return false;
}
private boolean fieldsChanged(Post original, Post updated) {
// Patch supports message, and reactions for now
return !original.getMessage().equals(updated.getMessage())
|| (Collections.isEmpty(original.getReactions())
&& !Collections.isEmpty(updated.getReactions()))
|| (!Collections.isEmpty(original.getReactions())
&& Collections.isEmpty(updated.getReactions()))
|| original.getReactions().size() != updated.getReactions().size()
|| !original.getReactions().containsAll(updated.getReactions());
}
private boolean fieldsChanged(Thread original, Thread updated) {
// Patch supports isResolved, message, task assignees, reactions, announcements and AI for now
return !original.getResolved().equals(updated.getResolved())
|| !original.getMessage().equals(updated.getMessage())
|| (Collections.isEmpty(original.getReactions())
&& !Collections.isEmpty(updated.getReactions()))
|| (!Collections.isEmpty(original.getReactions())
&& Collections.isEmpty(updated.getReactions()))
|| (original.getReactions() != null
&& updated.getReactions() != null
&& (original.getReactions().size() != updated.getReactions().size()
|| !original.getReactions().containsAll(updated.getReactions())))
|| (original.getAnnouncement() != null
&& (!original
.getAnnouncement()
.getDescription()
.equals(updated.getAnnouncement().getDescription())
|| !Objects.equals(
original.getAnnouncement().getStartTime(),
updated.getAnnouncement().getStartTime())
|| !Objects.equals(
original.getAnnouncement().getEndTime(),
updated.getAnnouncement().getEndTime())))
|| (original.getChatbot() == null && updated.getChatbot() != null)
|| (original.getChatbot() != null
&& updated.getChatbot() != null
&& !original.getChatbot().getQuery().equals(updated.getChatbot().getQuery()))
|| (original.getTask() != null
&& (original.getTask().getAssignees().size() != updated.getTask().getAssignees().size()
|| !original
.getTask()
.getAssignees()
.containsAll(updated.getTask().getAssignees())));
}
private void sortPosts(Thread thread) {
thread.getPosts().sort(Comparator.comparing(Post::getPostTs));
}
private void sortPostsInThreads(List threads) {
threads.forEach(this::sortPosts);
}
/** Limit the number of posts within each thread to the requested limitPosts. */
private void sortAndLimitPosts(List threads, int limitPosts) {
for (Thread t : threads) {
List posts = t.getPosts();
sortPosts(t);
if (posts.size() > limitPosts) {
// Only keep the last "n" number of posts
posts = posts.subList(posts.size() - limitPosts, posts.size());
t.withPosts(posts);
}
}
}
private String getUserTeamJsonMysql(UUID userId, List teamIds) {
List result = new ArrayList<>();
result.add("\"" + userId.toString() + "\"");
teamIds.forEach(id -> result.add("\"" + id + "\""));
return result.toString();
}
private String getUserTeamJsonPostgres(UUID userId, List teamIds) {
StringBuilder result = new StringBuilder();
result.append(userId.toString());
for (String id : teamIds) {
result.append(" | ").append(id);
}
LOG.info("result {}", result.toString());
return result.toString();
}
private JSONObject getUserTeamJson(UUID userId, String type) {
return new JSONObject().put("id", userId).put("type", type);
}
private JSONObject getUserTeamJson(String userId, String type) {
return new JSONObject().put("id", userId).put("type", type);
}
/** Return the tasks assigned to the user. */
private FilteredThreads getTasksAssignedTo(FeedFilter filter, UUID userId, int limit) {
List teamIds = getTeamIds(userId);
String userTeamJsonPostgres = getUserTeamJsonPostgres(userId, teamIds);
String userTeamJsonMysql = getUserTeamJsonMysql(userId, teamIds);
List jsons =
dao.feedDAO()
.listTasksAssigned(
userTeamJsonPostgres, userTeamJsonMysql, limit, filter.getCondition());
List threads = JsonUtils.readObjects(jsons, Thread.class);
int totalCount =
dao.feedDAO()
.listCountTasksAssignedTo(
userTeamJsonPostgres, userTeamJsonMysql, filter.getCondition(false));
return new FilteredThreads(threads, totalCount);
}
private void populateAssignees(List threads) {
threads.forEach(this::populateAssignees);
}
private Thread populateAssignees(Thread thread) {
if (thread != null && ThreadType.Task.equals(thread.getType())) {
List assignees = thread.getTask().getAssignees();
for (EntityReference ref : assignees) {
try {
EntityReference ref2 = Entity.getEntityReferenceById(ref.getType(), ref.getId(), ALL);
EntityUtil.copy(ref2, ref);
} catch (EntityNotFoundException exception) {
// mark the not found user as deleted user since
// user will not be found in case of permanent deletion of user or team
if (ref.getType().equals(Entity.TEAM)) {
ref.setName(DELETED_TEAM_NAME);
ref.setDisplayName(DELETED_TEAM_DISPLAY);
} else {
ref.setName(DELETED_USER_NAME);
ref.setDisplayName(DELETED_USER_DISPLAY);
}
}
}
assignees.sort(compareEntityReference);
thread.getTask().setAssignees(assignees);
}
return thread;
}
/** Return the tasks created by or assigned to the user. */
private FilteredThreads getTasksOfUser(FeedFilter filter, UUID userId, int limit) {
String username = Entity.getEntityReferenceById(Entity.USER, userId, ALL).getName();
List teamIds = getTeamIds(userId);
String userTeamJsonPostgres = getUserTeamJsonPostgres(userId, teamIds);
String userTeamJsonMysql = getUserTeamJsonMysql(userId, teamIds);
List jsons =
dao.feedDAO()
.listTasksOfUser(
userTeamJsonPostgres, userTeamJsonMysql, username, limit, filter.getCondition());
List threads = JsonUtils.readObjects(jsons, Thread.class);
int totalCount =
dao.feedDAO()
.listCountTasksOfUser(
userTeamJsonPostgres, userTeamJsonMysql, username, filter.getCondition(false));
return new FilteredThreads(threads, totalCount);
}
/** Return the tasks created by the user. */
private FilteredThreads getTasksAssignedBy(FeedFilter filter, UUID userId, int limit) {
String username = Entity.getEntityReferenceById(Entity.USER, userId, ALL).getName();
List jsons = dao.feedDAO().listTasksAssigned(username, limit, filter.getCondition());
List threads = JsonUtils.readObjects(jsons, Thread.class);
int totalCount = dao.feedDAO().listCountTasksAssignedBy(username, filter.getCondition(false));
return new FilteredThreads(threads, totalCount);
}
/**
* Return the threads associated with user/team owned entities and the threads that were created
* by or replied to by the user.
*/
private FilteredThreads getThreadsByOwner(FeedFilter filter, UUID userId, int limit) {
// add threads on user or team owned entities
// and threads created by or replied to by the user
List teamIds = getTeamIds(userId);
List jsons =
dao.feedDAO().listThreadsByOwner(userId, teamIds, limit, filter.getCondition());
List threads = JsonUtils.readObjects(jsons, Thread.class);
int totalCount =
dao.feedDAO().listCountThreadsByOwner(userId, teamIds, filter.getCondition(false));
return new FilteredThreads(threads, totalCount);
}
private FilteredThreads getThreadsForGlossary(
FeedFilter filter, UUID userId, int limit, EntityLink entityLink) {
User user = userId != null ? Entity.getEntity(USER, userId, TEAMS_FIELD, ALL) : null;
List teamNameHash = getTeamNames(user);
String userName = user == null ? null : user.getFullyQualifiedName();
int filterRelation = -1;
if (userName != null && filter.getFilterType() == FilterType.MENTIONS) {
filterRelation = MENTIONED_IN.ordinal();
}
EntityLink glossaryTermLink =
new EntityLink(GLOSSARY_TERM, entityLink.getFullyQualifiedFieldValue());
List jsons =
dao.feedDAO()
.listThreadsByGlossaryAndTerms(
entityLink.getFullyQualifiedFieldValue(),
entityLink.getFullyQualifiedFieldType(),
glossaryTermLink.getFullyQualifiedFieldType(),
limit + 1,
IS_ABOUT.ordinal(),
userName,
teamNameHash,
filterRelation,
filter.getCondition());
List threads = JsonUtils.readObjects(jsons, Thread.class);
return new FilteredThreads(threads, jsons.size());
}
/**
* Returns the threads where the user or the team they belong to were mentioned by other users
* with @mention.
*/
private FilteredThreads getThreadsByMentions(FeedFilter filter, UUID userId, int limit) {
User user = Entity.getEntity(Entity.USER, userId, TEAMS_FIELD, ALL);
String userNameHash = getUserNameHash(user);
// Return the threads where the user or team was mentioned
List teamNamesHash = getTeamNames(user);
// Return the threads where the user or team was mentioned
List jsons =
dao.feedDAO()
.listThreadsByMentions(
userNameHash,
teamNamesHash,
limit,
Relationship.MENTIONED_IN.ordinal(),
filter.getCondition());
List threads = JsonUtils.readObjects(jsons, Thread.class);
int totalCount =
dao.feedDAO()
.listCountThreadsByMentions(
userNameHash,
teamNamesHash,
Relationship.MENTIONED_IN.ordinal(),
filter.getCondition(false));
return new FilteredThreads(threads, totalCount);
}
/** Get a list of team ids that the given user is a part of. */
private List getTeamIds(UUID userId) {
List teamIds = null;
if (userId != null) {
User user = Entity.getEntity(Entity.USER, userId, TEAMS_FIELD, ALL);
teamIds = getTeamIds(user);
}
return nullOrEmpty(teamIds) ? List.of(StringUtils.EMPTY) : teamIds;
}
private List getTeamIds(User user) {
List teamIds =
listOrEmpty(user.getTeams()).stream().map(ref -> ref.getId().toString()).toList();
return nullOrEmpty(teamIds) ? List.of(StringUtils.EMPTY) : teamIds;
}
/** Returns the threads that are associated with the entities followed by the user. */
private FilteredThreads getThreadsByFollows(FeedFilter filter, UUID userId, int limit) {
List teamIds = getTeamIds(userId);
List jsons =
dao.feedDAO()
.listThreadsByFollows(
userId, teamIds, limit, Relationship.FOLLOWS.ordinal(), filter.getCondition());
List threads = JsonUtils.readObjects(jsons, Thread.class);
int totalCount =
dao.feedDAO()
.listCountThreadsByFollows(
userId, teamIds, Relationship.FOLLOWS.ordinal(), filter.getCondition());
return new FilteredThreads(threads, totalCount);
}
private FilteredThreads getThreadsByOwnerOrFollows(FeedFilter filter, UUID userId, int limit) {
List teamIds = getTeamIds(userId);
List jsons =
dao.feedDAO().listThreadsByOwnerOrFollows(userId, teamIds, limit, filter.getCondition());
List threads = JsonUtils.readObjects(jsons, Thread.class);
int totalCount =
dao.feedDAO().listCountThreadsByOwnerOrFollows(userId, teamIds, filter.getCondition());
return new FilteredThreads(threads, totalCount);
}
/** Get a list of team names that the given user is a part of. */
private List getTeamNames(User user) {
List teamNames = null;
if (user != null) {
teamNames =
listOrEmpty(user.getTeams()).stream()
.map(x -> FullyQualifiedName.buildHash(x.getFullyQualifiedName()))
.toList();
}
return nullOrEmpty(teamNames) ? List.of(StringUtils.EMPTY) : teamNames;
}
private String getUserNameHash(User user) {
return user != null ? FullyQualifiedName.buildHash(user.getFullyQualifiedName()) : null;
}
public static String resolveDescriptionTaskMessage(TaskDetails task) {
return String.format(
"Resolved the Task with Description - %s",
FEED_MESSAGE_FORMATTER.getPlaintextDiff(task.getOldValue(), task.getNewValue()));
}
public static String resolveTagTaskMessage(TaskDetails task) {
String oldValue =
task.getOldValue() != null
? getTagFQNs(JsonUtils.readObjects(task.getOldValue(), TagLabel.class))
: StringUtils.EMPTY;
String newValue = getTagFQNs(JsonUtils.readObjects(task.getNewValue(), TagLabel.class));
return String.format(
"Resolved the Task with Tag(s) - %s",
FEED_MESSAGE_FORMATTER.getPlaintextDiff(oldValue, newValue));
}
public static String closeTaskMessage(String closingComment) {
return String.format("Closed the Task with comment - %s", closingComment);
}
public record FilteredThreads(List threads, int totalCount) {}
}