Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add activity log #16

Merged
merged 6 commits into from
Nov 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions server/src/main/java/io/flexwork/config/AsyncConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.security.task.DelegatingSecurityContextTaskExecutor;
import tech.jhipster.async.ExceptionHandlingAsyncTaskExecutor;

@Configuration
Expand Down Expand Up @@ -41,6 +43,19 @@ public Executor getAsyncExecutor() {
return new ExceptionHandlingAsyncTaskExecutor(executor);
}

@Bean(name = "auditLogExecutor")
public TaskExecutor auditLogaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("SecurityContextAsync-");
executor.initialize();

// Wrap the executor with DelegatingSecurityContextTaskExecutor
return new DelegatingSecurityContextTaskExecutor(executor);
}

@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new SimpleAsyncUncaughtExceptionHandler();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package io.flexwork.modules.audit;

import io.flexwork.modules.collab.domain.EntityType;
import java.util.HashMap;
import java.util.Map;
import java.util.function.BiFunction;

public abstract class AbstractEntityFieldHandlerRegistry implements EntityFieldHandlerRegistry {

private final Map<String, BiFunction<Object, Object, String>> fieldHandlers = new HashMap<>();

/**
* Add a field handler for a specific field.
*
* @param fieldName The name of the field.
* @param handler A function that processes the old and new values of the field.
*/
protected void addFieldHandler(String fieldName, BiFunction<Object, Object, String> handler) {
fieldHandlers.put(fieldName, handler);
}

/**
* Get the handler for a specific field.
*
* @param fieldName The name of the field.
* @return A handler function that processes the old and new values of the field.
*/
@Override
public BiFunction<Object, Object, String> getHandler(String fieldName) {
return fieldHandlers.get(fieldName);
}

/**
* Initialize field handlers for this entity. Subclasses must define their field-specific logic
* in this method.
*/
protected abstract void initializeFieldHandlers();

/** Constructor to ensure field handlers are initialized in subclasses. */
public AbstractEntityFieldHandlerRegistry() {
initializeFieldHandlers();
}

/**
* Subclasses must provide the entity's class.
*
* @return The class of the entity this registry handles.
*/
@Override
public abstract Class<?> getEntityClass();

/**
* Subclasses must provide the entity's EntityType.
*
* @return The EntityType of the entity.
*/
@Override
public abstract EntityType getEntityType();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.flexwork.modules.audit;

import static j2html.TagCreator.*;

import j2html.tags.DomContent;
import java.util.List;

public class ActivityLogUtils {

public static String generateHtmlLog(List<AuditUtils.FieldChange> changes) {
DomContent htmlContent =
table().with(
thead(tr(th("Field"), th("Old Value"), th("New Value"))),
tbody(
each(
changes,
change ->
tr(
td(change.getFieldName()),
td(
change.getOldValue() != null
? change.getOldValue()
.toString()
: "N/A"),
td(
change.getNewValue() != null
? change.getNewValue()
.toString()
: "N/A")))));

return htmlContent.render();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package io.flexwork.modules.audit;

import lombok.Getter;
import org.springframework.context.ApplicationEvent;

@Getter
public class AuditLogUpdateEvent extends ApplicationEvent {

private final Object updatedEntity;

public AuditLogUpdateEvent(Object source, Object updatedEntity) {
super(source);
this.updatedEntity = updatedEntity;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package io.flexwork.modules.audit;

import io.flexwork.modules.collab.domain.ActivityLog;
import io.flexwork.modules.collab.domain.EntityType;
import io.flexwork.modules.collab.repository.ActivityLogRepository;
import io.flexwork.security.SecurityUtils;
import jakarta.persistence.EntityNotFoundException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Component
public class AuditLogUpdateEventListener {

private static final Logger LOG = LoggerFactory.getLogger(AuditLogUpdateEventListener.class);

private final ActivityLogRepository activityLogRepository;
private final EntityFieldHandlerRegistryFactory registryFactory;
private final ApplicationContext applicationContext;

public AuditLogUpdateEventListener(
ActivityLogRepository activityLogRepository,
EntityFieldHandlerRegistryFactory registryFactory,
ApplicationContext applicationContext) {
this.activityLogRepository = activityLogRepository;
this.registryFactory = registryFactory;
this.applicationContext = applicationContext;
}

@Async("auditLogExecutor")
@Transactional
@EventListener
public void onNewTeamRequestCreated(AuditLogUpdateEvent event) {
try {

Object updatedEntity = event.getUpdatedEntity();
Class<?> entityClass = updatedEntity.getClass();
Long entityId = extractEntityId(updatedEntity);

// Fetch the existing entity
Object existingEntity = fetchExistingEntity(entityClass, entityId);

// Convert existing entity to DTO form using the mapper
Object existingEntityDto = mapEntityToDto(existingEntity.getClass(), existingEntity);

// Get the registry for the entity
EntityFieldHandlerRegistry registry = registryFactory.getRegistry(entityClass);

// Find changes between the existing DTO and updated entity
List<AuditUtils.FieldChange> changes =
AuditUtils.findChanges(existingEntityDto, updatedEntity, registry);

if (!changes.isEmpty()) {
// Generate HTML content
String htmlLog = ActivityLogUtils.generateHtmlLog(changes);

// Save the aggregated activity log
saveActivityLog(registry.getEntityType(), entityId, htmlLog);
}
} catch (Exception e) {
// Log the exception
LOG.error("Error in async logEntityChanges", e);
}
}

private Object mapEntityToDto(Class<?> entityClass, Object entity) {
try {
// Derive the mapper bean name based on conventions (e.g., "teamMapper")
String mapperBeanName = getMapperBeanName(entityClass);

// Fetch the mapper bean from the Spring context
Object mapper = applicationContext.getBean(mapperBeanName);

// Find the "toDto" method in the mapper
Method toDtoMethod = mapper.getClass().getDeclaredMethod("toDto", entityClass);

// Invoke the "toDto" method and return the result
return toDtoMethod.invoke(mapper, entity);
} catch (Exception e) {
throw new RuntimeException(
"Failed to map entity to DTO for class: " + entityClass.getSimpleName(), e);
}
}

private String getMapperBeanName(Class<?> entityClass) {
// Get the simple name of the entity class
String entityName = entityClass.getSimpleName();

// Remove the "DTO" suffix if it exists
if (entityName.endsWith("DTO")) {
entityName = entityName.substring(0, entityName.length() - 3);
}

// Convert the entity name to the mapper bean name
String mapperBeanName =
Character.toLowerCase(entityName.charAt(0))
+ entityName.substring(1)
+ "MapperImpl";

return mapperBeanName;
}

private void saveActivityLog(EntityType entityType, Long entityId, String activityDetails) {
ActivityLog activityLog = new ActivityLog();
activityLog.setEntityType(entityType);
activityLog.setEntityId(entityId);
activityLog.setContent(activityDetails);
activityLog.setCreatedBy(SecurityUtils.getCurrentUserAuditorLogin());
activityLogRepository.save(activityLog);
}

private Object fetchExistingEntity(Class<?> entityClass, Long entityId) {
// Get the repository bean name for the entity
String repositoryBeanName = getRepositoryBeanName(entityClass);

// Fetch the repository dynamically
JpaRepository<?, Long> repository =
(JpaRepository<?, Long>) applicationContext.getBean(repositoryBeanName);

// Query the existing entity by ID
return repository
.findById(entityId)
.orElseThrow(
() ->
new EntityNotFoundException(
"Entity of class "
+ entityClass.getSimpleName()
+ " with ID "
+ entityId
+ " not found"));
}

private String getRepositoryBeanName(Class<?> entityClass) {
// Get the simple name of the entity class
String entityName = entityClass.getSimpleName();

// Remove "DTO" suffix if present
if (entityName.endsWith("DTO")) {
entityName = entityName.substring(0, entityName.length() - 3);
}

// Convert the entity name to the repository bean name
// Convention: repositoryBeanName = entityName (lowerCamelCase) + "Repository"
String repositoryBeanName =
Character.toLowerCase(entityName.charAt(0))
+ entityName.substring(1)
+ "Repository";

return repositoryBeanName;
}

private Long extractEntityId(Object entity) {
try {
Field idField = entity.getClass().getDeclaredField("id");
idField.setAccessible(true);
return (Long) idField.get(entity);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new RuntimeException("Failed to extract entity ID", e);
}
}
}
Loading
Loading