Skip to content
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
2 changes: 2 additions & 0 deletions backend/exence/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ dependencies {
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.mail)
implementation(libs.spring.boot.starter.validation)
// Audit logging
implementation(libs.javers.spring.boot.starter.sql)

// JWT
implementation(libs.jjwt.api)
Expand Down
2 changes: 2 additions & 0 deletions backend/exence/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mockito = "5.15.2"
querydsl = "5.1.0"
argon2-jvm = "2.11"
jacoco = "0.8.12"
javers = "7.9.0"

[libraries]
spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa" }
Expand All @@ -37,6 +38,7 @@ mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
querydsl-jpa = { module = "com.querydsl:querydsl-jpa", version.ref = "querydsl" }
querydsl-apt = { module = "com.querydsl:querydsl-apt", version.ref = "querydsl" }
argon2-jvm = { module = "de.mkammerer:argon2-jvm", version.ref = "argon2-jvm" }
javers-spring-boot-starter-sql = { module = "org.javers:javers-spring-boot-starter-sql", version.ref = "javers" }

[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.exence.finance.common.dto;

import java.util.List;
import org.springframework.data.domain.Slice;

public record SliceResponse<T>(
List<T> content, int page, int size, boolean first, boolean last, boolean hasNext, int numberOfElements) {

public static <T> SliceResponse<T> from(Slice<T> slice) {
return new SliceResponse<>(
slice.getContent(),
slice.getNumber(),
slice.getSize(),
slice.isFirst(),
slice.isLast(),
slice.hasNext(),
slice.getNumberOfElements());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import lombok.experimental.SuperBuilder;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.javers.core.metamodel.annotation.DiffIgnore;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
Expand All @@ -24,18 +25,22 @@
@MappedSuperclass
public abstract class BaseAuditableEntity {

@DiffIgnore
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;

@DiffIgnore
@UpdateTimestamp
@Column(name = "updated_at")
private Instant updatedAt;

@DiffIgnore
@CreatedBy
@Column(name = "created_by", nullable = false, updatable = false)
private String createdBy;

@DiffIgnore
@LastModifiedBy
@Column(name = "updated_by")
private String updatedBy;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import lombok.NoArgsConstructor;
import lombok.Setter;
import lombok.experimental.SuperBuilder;
import org.javers.core.metamodel.annotation.DiffIgnore;

@Getter
@Setter
Expand All @@ -19,6 +20,7 @@
@MappedSuperclass
public abstract class BaseWorkspaceEntity extends BaseAuditableEntity {

@DiffIgnore
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "workspace_id", nullable = false)
private Workspace workspace;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package com.exence.finance.common.util;

import com.exence.finance.common.dto.PageResponse;
import com.exence.finance.common.dto.SliceResponse;
import java.net.URI;
import lombok.experimental.UtilityClass;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Slice;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -36,6 +38,10 @@ public static <T> ResponseEntity<PageResponse<T>> page(Page<T> page) {
return ResponseEntity.ok(PageResponse.from(page));
}

public static <T> ResponseEntity<SliceResponse<T>> slice(Slice<T> slice) {
return ResponseEntity.ok(SliceResponse.from(slice));
}

public static <T> ResponseEntity<T> okWithCookies(T body, ResponseCookie... cookies) {
var builder = ResponseEntity.ok();
for (ResponseCookie cookie : cookies) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.exence.finance.config;

import com.exence.finance.modules.workspace.context.WorkspaceContextHolder;
import java.util.Map;
import org.javers.spring.auditable.AuthorProvider;
import org.javers.spring.auditable.CommitPropertiesProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;

@Configuration
public class JaversConfig {

@Bean
public AuthorProvider authorProvider() {
return () -> {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)) {
return auth.getName();
}
return "SYSTEM";
};
}

@Bean
public CommitPropertiesProvider commitPropertiesProvider() {
return new CommitPropertiesProvider() {
@Override
public Map<String, String> provideForCommittedObject(Object domainObject) {
if (WorkspaceContextHolder.isSet()) {
return Map.of(
"workspaceId",
WorkspaceContextHolder.getWorkspaceId().toString());
}
return Map.of();
}
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.exence.finance.modules.auditlog.controller;

import com.exence.finance.common.dto.SliceResponse;
import com.exence.finance.modules.auditlog.dto.AuditLogDTO;
import com.exence.finance.modules.auditlog.dto.AuditLogFilter;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;

public interface AdminAuditLogController {

ResponseEntity<SliceResponse<AuditLogDTO>> getAllAuditLogs(AuditLogFilter filter, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.exence.finance.modules.auditlog.controller;

import com.exence.finance.common.dto.SliceResponse;
import com.exence.finance.modules.auditlog.dto.AuditLogDTO;
import com.exence.finance.modules.auditlog.dto.AuditLogFilter;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;

public interface AuditLogController {

ResponseEntity<SliceResponse<AuditLogDTO>> getWorkspaceAuditLogs(AuditLogFilter filter, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.exence.finance.modules.auditlog.controller.impl;

import static com.exence.finance.common.util.ApplicationConstants.DEFAULT_PAGE_SIZE;

import com.exence.finance.common.dto.SliceResponse;
import com.exence.finance.common.util.ResponseFactory;
import com.exence.finance.modules.auditlog.controller.AdminAuditLogController;
import com.exence.finance.modules.auditlog.dto.AuditLogDTO;
import com.exence.finance.modules.auditlog.dto.AuditLogFilter;
import com.exence.finance.modules.auditlog.service.AuditLogService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/admin/audit-logs")
@CrossOrigin(origins = "http://localhost:4200")
@RequiredArgsConstructor
public class AdminAuditLogControllerImpl implements AdminAuditLogController {

private final AuditLogService auditLogService;

@Override
@GetMapping
public ResponseEntity<SliceResponse<AuditLogDTO>> getAllAuditLogs(
@ModelAttribute AuditLogFilter filter,
@PageableDefault(size = DEFAULT_PAGE_SIZE, sort = "changedAt", direction = Sort.Direction.DESC)
Pageable pageable) {
return ResponseFactory.slice(auditLogService.getAllAuditLogs(filter, pageable));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.exence.finance.modules.auditlog.controller.impl;

import static com.exence.finance.common.util.ApplicationConstants.DEFAULT_PAGE_SIZE;

import com.exence.finance.common.dto.SliceResponse;
import com.exence.finance.common.util.ResponseFactory;
import com.exence.finance.modules.auditlog.controller.AuditLogController;
import com.exence.finance.modules.auditlog.dto.AuditLogDTO;
import com.exence.finance.modules.auditlog.dto.AuditLogFilter;
import com.exence.finance.modules.auditlog.service.AuditLogService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/audit-logs")
@CrossOrigin(origins = "http://localhost:4200")
@RequiredArgsConstructor
public class AuditLogControllerImpl implements AuditLogController {

private final AuditLogService auditLogService;

@Override
@GetMapping()
public ResponseEntity<SliceResponse<AuditLogDTO>> getWorkspaceAuditLogs(
@ModelAttribute AuditLogFilter filter,
@PageableDefault(size = DEFAULT_PAGE_SIZE, sort = "changedAt", direction = Sort.Direction.DESC)
Pageable pageable) {
return ResponseFactory.slice(auditLogService.getWorkspaceAuditLogs(filter, pageable));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.exence.finance.modules.auditlog.dto;

public record AuditLogChangeDTO(String field, String from, String to) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.exence.finance.modules.auditlog.dto;

import com.exence.finance.modules.auditlog.enums.ChangeType;
import java.time.Instant;
import java.util.List;

public record AuditLogDTO(
String entityType,
String entityId,
ChangeType action,
Instant changedAt,
String changedBy,
List<AuditLogChangeDTO> changes) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.exence.finance.modules.auditlog.dto;

import com.exence.finance.modules.auditlog.enums.AuditableEntityType;
import com.exence.finance.modules.auditlog.enums.ChangeType;
import java.time.LocalDate;

public record AuditLogFilter(
AuditableEntityType entityType, LocalDate from, LocalDate to, ChangeType changeType, String changedBy) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.exence.finance.modules.auditlog.enums;

import com.exence.finance.modules.category.entity.Category;
import com.exence.finance.modules.debt.entity.Debt;
import com.exence.finance.modules.goal.entity.Goal;
import com.exence.finance.modules.investment.entity.Investment;
import com.exence.finance.modules.transaction.entity.RecurringTransaction;
import com.exence.finance.modules.transaction.entity.Transaction;
import com.exence.finance.modules.workspace.entity.Workspace;
import com.exence.finance.modules.workspace.entity.WorkspaceMember;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum AuditableEntityType {
TRANSACTION(Transaction.class),
CATEGORY(Category.class),
GOAL(Goal.class),
DEBT(Debt.class),
INVESTMENT(Investment.class),
RECURRING_TRANSACTION(RecurringTransaction.class),
WORKSPACE(Workspace.class),
WORKSPACE_MEMBER(WorkspaceMember.class);

private final Class<?> entityClass;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.exence.finance.modules.auditlog.enums;

public enum ChangeType {
CREATED,
UPDATED,
DELETED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.exence.finance.modules.auditlog.mapper;

import com.exence.finance.modules.auditlog.dto.AuditLogChangeDTO;
import com.exence.finance.modules.auditlog.dto.AuditLogDTO;
import com.exence.finance.modules.auditlog.enums.ChangeType;
import java.time.ZoneOffset;
import java.util.List;
import java.util.stream.Collectors;
import org.javers.core.diff.Change;
import org.javers.core.diff.changetype.NewObject;
import org.javers.core.diff.changetype.ObjectRemoved;
import org.javers.core.diff.changetype.ValueChange;
import org.javers.core.metamodel.object.GlobalId;
import org.springframework.stereotype.Component;

@Component
public class AuditLogMapper {

public AuditLogDTO toAuditLogDTO(List<Change> group) {
Change first = group.get(0);
var commitMeta = first.getCommitMetadata().get();
GlobalId globalId = first.getAffectedGlobalId();

String entityType = extractEntityType(globalId);
String entityId = extractEntityId(globalId);
String changedBy = commitMeta.getAuthor();
var changedAt = commitMeta.getCommitDate().toInstant(ZoneOffset.UTC);

boolean isCreated = group.stream().anyMatch(c -> c instanceof NewObject);
boolean isDeleted = group.stream().anyMatch(c -> c instanceof ObjectRemoved);

ChangeType action;
List<AuditLogChangeDTO> fieldChanges;

if (isCreated) {
action = ChangeType.CREATED;
fieldChanges = List.of();
} else if (isDeleted) {
action = ChangeType.DELETED;
fieldChanges = List.of();
} else {
action = ChangeType.UPDATED;
fieldChanges = group.stream()
.filter(c -> c instanceof ValueChange)
.map(c -> (ValueChange) c)
.map(vc -> new AuditLogChangeDTO(
vc.getPropertyName(),
vc.getLeft() != null ? vc.getLeft().toString() : null,
vc.getRight() != null ? vc.getRight().toString() : null))
.collect(Collectors.toList());
}

return new AuditLogDTO(entityType, entityId, action, changedAt, changedBy, fieldChanges);
}

private String extractEntityType(GlobalId globalId) {
String typeName = globalId.getTypeName();
int lastDot = typeName.lastIndexOf('.');
return lastDot >= 0 ? typeName.substring(lastDot + 1) : typeName;
}

private String extractEntityId(GlobalId globalId) {
String raw = globalId.toString();
int slash = raw.lastIndexOf('/');
return slash >= 0 ? raw.substring(slash + 1) : raw;
}
}
Loading
Loading