feat(election): production-ready audit log with server-side filter + pagination

- Add m_election_audit_log table via Liquibase (0341)
- ElectionAuditLog entity, ElectionAuditAction enum (21 actions)
- ElectionAuditService: log() with double try-catch, getAuditLogPaged()
  with entityType/action/full-text filters, size cap at 100, page safety
- ElectionAuditLogRepository: findWithFilters() JPQL with optional params
- All 4 election API resources wired with audit calls (create/update/
  activate/nominations/voting/candidates/votes/results)
- GET /{electionId}/audit-log accepts ?entityType=&action=&search=&page=&size=

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-29 12:57:21 +05:30
co-authored by Claude Sonnet 4.6
parent 3d30bdb788
commit 69513f25f3
13 changed files with 536 additions and 20 deletions
@@ -2,6 +2,7 @@ package org.apache.fineract.portfolio.election.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
@@ -11,13 +12,16 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService;
import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.glassfish.jersey.media.multipart.FormDataBodyPart;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataParam;
@@ -40,6 +44,7 @@ public class ElectionCandidateApiResource {
private final ElectionWritePlatformService writeService;
private final PlatformSecurityContext context;
private final ContentStoreService contentStoreService;
private final ElectionAuditService electionAuditService;
@GET
@Path("/{electionId}/candidates")
@@ -58,9 +63,12 @@ public class ElectionCandidateApiResource {
@Path("/{electionId}/nomination/{nominationId}/approve-candidate")
@Operation(summary = "Approve nomination and add to candidates list")
public Response approveCandidate(@PathParam("electionId") Long electionId,
@PathParam("nominationId") Long nominationId) {
@PathParam("nominationId") Long nominationId,
@Context HttpServletRequest httpRequest) {
Long approvedBy = context.authenticatedUser().getId();
Long candidateId = writeService.approveCandidate(nominationId, approvedBy);
electionAuditService.log(ElectionAuditAction.APPROVE_CANDIDATE, "CANDIDATE", candidateId, electionId,
"Nomination approved as candidate", null, clientIp(httpRequest));
return Response.ok(Map.of("resourceId", candidateId)).build();
}
@@ -69,8 +77,11 @@ public class ElectionCandidateApiResource {
@Operation(summary = "Update candidate symbol or brief profile")
public Response updateCandidate(@PathParam("electionId") Long electionId,
@PathParam("candidateId") Long candidateId,
Map<String, String> body) {
Map<String, String> body,
@Context HttpServletRequest httpRequest) {
writeService.updateCandidate(candidateId, body.get("symbol"), body.get("briefProfile"));
electionAuditService.log(ElectionAuditAction.UPDATE_CANDIDATE, "CANDIDATE", candidateId, electionId,
"Candidate updated", null, clientIp(httpRequest));
return Response.ok(Map.of("resourceId", candidateId)).build();
}
@@ -82,7 +93,8 @@ public class ElectionCandidateApiResource {
@PathParam("candidateId") Long candidateId,
@FormDataParam("file") InputStream fileStream,
@FormDataParam("file") FormDataContentDisposition fileDetail,
@FormDataParam("file") FormDataBodyPart filePart) {
@FormDataParam("file") FormDataBodyPart filePart,
@Context HttpServletRequest httpRequest) {
String mimeType = (filePart != null && filePart.getMediaType() != null)
? filePart.getMediaType().toString() : "image/png";
@@ -104,6 +116,8 @@ public class ElectionCandidateApiResource {
// use the returned (sanitized/normalized) path, not the pre-computed one
String savedPath = contentStoreService.upload(storagePath, fileStream, mimeType);
writeService.uploadCandidateSymbol(candidateId, mimeType + "|" + savedPath);
electionAuditService.log(ElectionAuditAction.UPLOAD_SYMBOL, "CANDIDATE", candidateId, electionId,
"Election symbol uploaded", null, clientIp(httpRequest));
return Response.ok(Map.of("resourceId", candidateId)).build();
}
@@ -146,8 +160,22 @@ public class ElectionCandidateApiResource {
@Operation(summary = "Remove a candidate from the election")
public Response removeCandidate(@PathParam("electionId") Long electionId,
@PathParam("candidateId") Long candidateId,
@QueryParam("reason") String reason) {
@QueryParam("reason") String reason,
@Context HttpServletRequest httpRequest) {
writeService.removeCandidate(candidateId, reason);
electionAuditService.log(ElectionAuditAction.REMOVE_CANDIDATE, "CANDIDATE", candidateId, electionId,
"Candidate removed: " + reason, null, clientIp(httpRequest));
return Response.ok(Map.of("resourceId", candidateId, "status", "REMOVED")).build();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private static String clientIp(HttpServletRequest req) {
if (req == null) return null;
String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
return req.getRemoteAddr();
}
}
@@ -3,6 +3,7 @@ package org.apache.fineract.portfolio.election.api;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
@@ -11,11 +12,15 @@ import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.election.data.ElectionConfigRequest;
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService;
import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService;
import org.springframework.stereotype.Component;
@@ -33,6 +38,7 @@ public class ElectionConfigApiResource {
private final ElectionReadPlatformService readService;
private final ElectionWritePlatformService writeService;
private final ObjectMapper objectMapper;
private final ElectionAuditService electionAuditService;
@GET
@Operation(summary = "List all elections (paginated)")
@@ -51,8 +57,13 @@ public class ElectionConfigApiResource {
@POST
@Operation(summary = "Create a new election")
public Response createElection(ElectionConfigRequest request) {
public Response createElection(ElectionConfigRequest request,
@Context HttpServletRequest httpRequest) {
Long id = writeService.createElection(request);
electionAuditService.log(ElectionAuditAction.CREATE_ELECTION, "ELECTION", id, id,
"Election created: " + request.getTitle(),
request.toString(),
clientIp(httpRequest));
return Response.ok(Map.of("resourceId", id)).build();
}
@@ -60,40 +71,55 @@ public class ElectionConfigApiResource {
@Path("/{electionId}")
@Operation(summary = "Update election configuration")
public Response updateElection(@PathParam("electionId") Long electionId,
ElectionConfigRequest request) {
ElectionConfigRequest request,
@Context HttpServletRequest httpRequest) {
writeService.updateElection(electionId, request);
electionAuditService.log(ElectionAuditAction.UPDATE_ELECTION, "ELECTION", electionId, electionId,
"Election updated", null, clientIp(httpRequest));
return Response.ok(Map.of("resourceId", electionId)).build();
}
@POST
@Path("/{electionId}/activate")
@Operation(summary = "Activate election to open nominations")
public Response activateElection(@PathParam("electionId") Long electionId) {
public Response activateElection(@PathParam("electionId") Long electionId,
@Context HttpServletRequest httpRequest) {
writeService.activateElection(electionId);
electionAuditService.log(ElectionAuditAction.ACTIVATE_ELECTION, "ELECTION", electionId, electionId,
"Election activated", null, clientIp(httpRequest));
return Response.ok(Map.of("resourceId", electionId, "status", "ACTIVE")).build();
}
@POST
@Path("/{electionId}/close-nominations")
@Operation(summary = "Close nomination window")
public Response closeNominations(@PathParam("electionId") Long electionId) {
public Response closeNominations(@PathParam("electionId") Long electionId,
@Context HttpServletRequest httpRequest) {
writeService.closeNominations(electionId);
electionAuditService.log(ElectionAuditAction.CLOSE_NOMINATIONS, "ELECTION", electionId, electionId,
"Nominations closed", null, clientIp(httpRequest));
return Response.ok(Map.of("resourceId", electionId, "status", "NOMINATION_CLOSED")).build();
}
@POST
@Path("/{electionId}/open-voting")
@Operation(summary = "Open voting")
public Response openVoting(@PathParam("electionId") Long electionId) {
public Response openVoting(@PathParam("electionId") Long electionId,
@Context HttpServletRequest httpRequest) {
writeService.openVoting(electionId);
electionAuditService.log(ElectionAuditAction.OPEN_VOTING, "ELECTION", electionId, electionId,
"Voting opened", null, clientIp(httpRequest));
return Response.ok(Map.of("resourceId", electionId, "status", "VOTING_OPEN")).build();
}
@POST
@Path("/{electionId}/close-voting")
@Operation(summary = "Close voting")
public Response closeVoting(@PathParam("electionId") Long electionId) {
public Response closeVoting(@PathParam("electionId") Long electionId,
@Context HttpServletRequest httpRequest) {
writeService.closeVoting(electionId);
electionAuditService.log(ElectionAuditAction.CLOSE_VOTING, "ELECTION", electionId, electionId,
"Voting closed", null, clientIp(httpRequest));
return Response.ok(Map.of("resourceId", electionId, "status", "VOTING_CLOSED")).build();
}
@@ -101,8 +127,36 @@ public class ElectionConfigApiResource {
@Path("/{electionId}/cancel")
@Operation(summary = "Cancel election")
public Response cancelElection(@PathParam("electionId") Long electionId,
@QueryParam("reason") String reason) {
@QueryParam("reason") String reason,
@Context HttpServletRequest httpRequest) {
writeService.cancelElection(electionId, reason);
electionAuditService.log(ElectionAuditAction.CANCEL_ELECTION, "ELECTION", electionId, electionId,
"Election cancelled: " + reason, null, clientIp(httpRequest));
return Response.ok(Map.of("resourceId", electionId, "status", "CANCELLED")).build();
}
@GET
@Path("/{electionId}/audit-log")
@Operation(summary = "Get paginated, filtered audit log for an election")
public Response getAuditLog(
@PathParam("electionId") Long electionId,
@QueryParam("entityType") String entityType,
@QueryParam("action") String action,
@QueryParam("search") String search,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("20") int size) {
return Response.ok(electionAuditService.getAuditLogPaged(
electionId, entityType, action, search, page, size)).build();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private static String clientIp(HttpServletRequest req) {
if (req == null) return null;
String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
return req.getRemoteAddr();
}
}
@@ -2,6 +2,7 @@ package org.apache.fineract.portfolio.election.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
@@ -9,11 +10,14 @@ import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.election.data.ElectionApprovalActionRequest;
import org.apache.fineract.portfolio.election.data.ElectionNominationRequest;
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService;
import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService;
import org.springframework.stereotype.Component;
@@ -30,14 +34,20 @@ public class ElectionNominationApiResource {
private final ElectionReadPlatformService readService;
private final ElectionWritePlatformService writeService;
private final ElectionAuditService electionAuditService;
@POST
@Path("/{electionId}/nomination")
@Operation(summary = "Submit a nomination for election")
public Response submitNomination(@PathParam("electionId") Long electionId,
ElectionNominationRequest request) {
ElectionNominationRequest request,
@Context HttpServletRequest httpRequest) {
request.setElectionId(electionId);
Long id = writeService.submitNomination(request);
electionAuditService.log(ElectionAuditAction.SUBMIT_NOMINATION, "NOMINATION", id, electionId,
"Nomination submitted by member " + request.getMemberId(),
null,
clientIp(httpRequest));
return Response.ok(Map.of("resourceId", id)).build();
}
@@ -63,8 +73,11 @@ public class ElectionNominationApiResource {
@Path("/{electionId}/nomination/{nominationId}/withdraw")
@Operation(summary = "Withdraw nomination")
public Response withdrawNomination(@PathParam("electionId") Long electionId,
@PathParam("nominationId") Long nominationId) {
@PathParam("nominationId") Long nominationId,
@Context HttpServletRequest httpRequest) {
writeService.withdrawNomination(nominationId);
electionAuditService.log(ElectionAuditAction.WITHDRAW_NOMINATION, "NOMINATION", nominationId, electionId,
"Nomination withdrawn", null, clientIp(httpRequest));
return Response.ok(Map.of("resourceId", nominationId, "status", "WITHDRAWN")).build();
}
@@ -72,8 +85,11 @@ public class ElectionNominationApiResource {
@Path("/{electionId}/nomination/{nominationId}/resubmit")
@Operation(summary = "Re-submit a rejected nomination for review after resolving issues")
public Response resubmitNomination(@PathParam("electionId") Long electionId,
@PathParam("nominationId") Long nominationId) {
@PathParam("nominationId") Long nominationId,
@Context HttpServletRequest httpRequest) {
writeService.resubmitNomination(nominationId);
electionAuditService.log(ElectionAuditAction.RESUBMIT_NOMINATION, "NOMINATION", nominationId, electionId,
"Nomination resubmitted", null, clientIp(httpRequest));
return Response.ok(Map.of("resourceId", nominationId, "status", "UNDER_REVIEW")).build();
}
@@ -81,8 +97,11 @@ public class ElectionNominationApiResource {
@Path("/{electionId}/nomination/{nominationId}/eligibility-check")
@Operation(summary = "Run automated eligibility check (age, gender, caste, shares, area)")
public Response runEligibilityCheck(@PathParam("electionId") Long electionId,
@PathParam("nominationId") Long nominationId) {
@PathParam("nominationId") Long nominationId,
@Context HttpServletRequest httpRequest) {
writeService.runEligibilityCheck(nominationId);
electionAuditService.log(ElectionAuditAction.ELIGIBILITY_CHECK, "NOMINATION", nominationId, electionId,
"Eligibility check run", null, clientIp(httpRequest));
return Response.ok(readService.retrieveNomination(nominationId)).build();
}
@@ -90,8 +109,11 @@ public class ElectionNominationApiResource {
@Path("/{electionId}/nomination/{nominationId}/ceo-precheck")
@Operation(summary = "Run CEO pre-check (director loan, relation accounts)")
public Response runCeoPreCheck(@PathParam("electionId") Long electionId,
@PathParam("nominationId") Long nominationId) {
@PathParam("nominationId") Long nominationId,
@Context HttpServletRequest httpRequest) {
writeService.runCeoPreCheck(nominationId);
electionAuditService.log(ElectionAuditAction.CEO_PRECHECK, "NOMINATION", nominationId, electionId,
"CEO pre-check run", null, clientIp(httpRequest));
return Response.ok(readService.retrieveNomination(nominationId)).build();
}
@@ -109,8 +131,11 @@ public class ElectionNominationApiResource {
@Operation(summary = "Process approval step (approve or reject)")
public Response processApprovalStep(@PathParam("electionId") Long electionId,
@PathParam("nominationId") Long nominationId,
ElectionApprovalActionRequest request) {
ElectionApprovalActionRequest request,
@Context HttpServletRequest httpRequest) {
writeService.processApprovalStep(nominationId, request);
electionAuditService.log(ElectionAuditAction.APPROVAL_ACTION, "NOMINATION", nominationId, electionId,
"Approval action: " + request.getAction(), null, clientIp(httpRequest));
return Response.ok(readService.retrieveNomination(nominationId)).build();
}
@@ -120,4 +145,15 @@ public class ElectionNominationApiResource {
public Response getNominationsByMember(@PathParam("memberId") Long memberId) {
return Response.ok(readService.retrieveNominationsByMember(memberId)).build();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private static String clientIp(HttpServletRequest req) {
if (req == null) return null;
String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
return req.getRemoteAddr();
}
}
@@ -2,18 +2,22 @@ package org.apache.fineract.portfolio.election.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.election.data.ElectionVoteRequest;
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
import org.apache.fineract.portfolio.election.domain.ElectionVoteRepository;
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService;
import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService;
import org.springframework.stereotype.Component;
@@ -34,23 +38,34 @@ public class ElectionVotingApiResource {
private final ElectionWritePlatformService writeService;
private final PlatformSecurityContext context;
private final ElectionVoteRepository voteRepository;
private final ElectionAuditService electionAuditService;
@POST
@Path("/{electionId}/vote")
@Operation(summary = "Cast a vote for a candidate")
public Response castVote(@PathParam("electionId") Long electionId,
ElectionVoteRequest request) {
ElectionVoteRequest request,
@Context HttpServletRequest httpRequest) {
request.setElectionId(electionId);
writeService.castVote(request);
electionAuditService.log(ElectionAuditAction.CAST_VOTE, "VOTE", null, electionId,
"Vote cast for post " + request.getPostId() + " candidate " + request.getCandidateId(),
"{\"postId\":" + request.getPostId()
+ ",\"candidateId\":" + request.getCandidateId()
+ ",\"voterMemberId\":" + request.getVoterMemberId() + "}",
clientIp(httpRequest));
return Response.ok(Map.of("message", "Vote cast successfully")).build();
}
@POST
@Path("/{electionId}/declare-results")
@Operation(summary = "Declare election results")
public Response declareResults(@PathParam("electionId") Long electionId) {
public Response declareResults(@PathParam("electionId") Long electionId,
@Context HttpServletRequest httpRequest) {
Long declaredBy = context.authenticatedUser().getId();
writeService.declareResults(electionId, declaredBy);
electionAuditService.log(ElectionAuditAction.DECLARE_RESULTS, "RESULT", electionId, electionId,
"Results declared", null, clientIp(httpRequest));
return Response.ok(readService.retrieveResultsByElection(electionId)).build();
}
@@ -84,4 +99,15 @@ public class ElectionVotingApiResource {
summary.put("breakdown", breakdown);
return Response.ok(summary).build();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private static String clientIp(HttpServletRequest req) {
if (req == null) return null;
String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
return req.getRemoteAddr();
}
}
@@ -0,0 +1,27 @@
package org.apache.fineract.portfolio.election.data;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ElectionAuditLogData {
private Long id;
private Long electionId;
private String entityType;
private Long entityId;
private String action;
private Long performedById;
private String performedByName;
private Long officeId;
private String ipAddress;
private String details;
private String description;
/** Formatted as "dd MMM yyyy HH:mm:ss" */
private String createdAt;
}
@@ -0,0 +1,17 @@
package org.apache.fineract.portfolio.election.domain;
public enum ElectionAuditAction {
// Election Config
CREATE_ELECTION, UPDATE_ELECTION, ACTIVATE_ELECTION,
CLOSE_NOMINATIONS, OPEN_VOTING, CLOSE_VOTING, CANCEL_ELECTION,
CREATE_POST, UPDATE_POST,
// Nomination
SUBMIT_NOMINATION, WITHDRAW_NOMINATION, RESUBMIT_NOMINATION,
ELIGIBILITY_CHECK, CEO_PRECHECK, APPROVAL_ACTION,
// Candidate
APPROVE_CANDIDATE, REMOVE_CANDIDATE, UPLOAD_SYMBOL, UPDATE_CANDIDATE,
// Voting
CAST_VOTE,
// Results
DECLARE_RESULTS
}
@@ -0,0 +1,68 @@
package org.apache.fineract.portfolio.election.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Entity
@Table(name = "m_election_audit_log")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ElectionAuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "election_id")
private Long electionId;
@Column(name = "entity_type", nullable = false, length = 50)
private String entityType;
@Column(name = "entity_id")
private Long entityId;
@Column(name = "action", nullable = false, length = 100)
private String action;
@Column(name = "performed_by_id")
private Long performedById;
@Column(name = "performed_by_name", length = 100)
private String performedByName;
@Column(name = "office_id")
private Long officeId;
@Column(name = "ip_address", length = 50)
private String ipAddress;
@Column(name = "details", columnDefinition = "TEXT")
private String details;
@Column(name = "description", length = 500)
private String description;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = LocalDateTime.now();
}
}
@@ -0,0 +1,31 @@
package org.apache.fineract.portfolio.election.domain;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface ElectionAuditLogRepository extends JpaRepository<ElectionAuditLog, Long> {
List<ElectionAuditLog> findByEntityTypeAndEntityIdOrderByCreatedAtDesc(String entityType, Long entityId);
@Query("SELECT a FROM ElectionAuditLog a WHERE a.electionId = :electionId " +
"AND (:entityType IS NULL OR a.entityType = :entityType) " +
"AND (:action IS NULL OR a.action = :action) " +
"AND (:search IS NULL " +
" OR (a.description IS NOT NULL AND LOWER(a.description) LIKE LOWER(CONCAT('%', :search, '%'))) " +
" OR (a.performedByName IS NOT NULL AND LOWER(a.performedByName) LIKE LOWER(CONCAT('%', :search, '%'))) " +
" OR LOWER(a.action) LIKE LOWER(CONCAT('%', :search, '%'))) " +
"ORDER BY a.createdAt DESC")
Page<ElectionAuditLog> findWithFilters(
@Param("electionId") Long electionId,
@Param("entityType") String entityType,
@Param("action") String action,
@Param("search") String search,
Pageable pageable);
}
@@ -0,0 +1,32 @@
package org.apache.fineract.portfolio.election.service;
import org.apache.fineract.portfolio.election.data.ElectionAuditLogData;
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
import java.util.List;
import java.util.Map;
public interface ElectionAuditService {
/**
* Record an audit event. Swallows all exceptions so a logging failure
* never propagates to the caller.
*/
void log(ElectionAuditAction action, String entityType, Long entityId,
Long electionId, String description, String details, String ipAddress);
/**
* Server-side filtered + paginated audit log for an election.
*
* @param entityType filter by entity type (null = all)
* @param action filter by action enum name (null = all)
* @param search full-text search on description / performedByName / action (null = all)
* @param page zero-based page index
* @param size page size (capped at 100)
*/
Map<String, Object> getAuditLogPaged(Long electionId, String entityType,
String action, String search,
int page, int size);
List<ElectionAuditLogData> getEntityAuditLog(String entityType, Long entityId);
}
@@ -0,0 +1,133 @@
package org.apache.fineract.portfolio.election.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.election.data.ElectionAuditLogData;
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
import org.apache.fineract.portfolio.election.domain.ElectionAuditLog;
import org.apache.fineract.portfolio.election.domain.ElectionAuditLogRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ElectionAuditServiceImpl implements ElectionAuditService {
private static final DateTimeFormatter DISPLAY_FORMATTER =
DateTimeFormatter.ofPattern("dd MMM yyyy HH:mm:ss");
private final ElectionAuditLogRepository auditLogRepository;
private final PlatformSecurityContext context;
@Override
@Transactional
public void log(ElectionAuditAction action, String entityType, Long entityId,
Long electionId, String description, String details, String ipAddress) {
try {
Long performedById = null;
String performedByName = null;
Long officeId = null;
try {
var user = context.authenticatedUser();
performedById = user.getId();
performedByName = user.getUsername();
if (user.getOffice() != null) {
officeId = user.getOffice().getId();
}
} catch (Exception ignored) {
// not authenticated — leave fields null
}
ElectionAuditLog entry = ElectionAuditLog.builder()
.electionId(electionId)
.entityType(entityType)
.entityId(entityId)
.action(action.name())
.performedById(performedById)
.performedByName(performedByName)
.officeId(officeId)
.ipAddress(ipAddress)
.details(details)
.description(description)
.build();
auditLogRepository.save(entry);
} catch (Exception e) {
log.warn("Election audit log failed — action={}, entityType={}, entityId={}: {}",
action, entityType, entityId, e.getMessage());
}
}
@Override
@Transactional(readOnly = true)
public Map<String, Object> getAuditLogPaged(Long electionId, String entityType,
String action, String search,
int page, int size) {
int cappedSize = Math.min(Math.max(size, 1), 100);
int safePage = Math.max(page, 0);
String entityTypeParam = (entityType != null && !entityType.isBlank()) ? entityType : null;
String actionParam = (action != null && !action.isBlank()) ? action : null;
String searchParam = (search != null && !search.isBlank()) ? search.trim() : null;
Pageable pageable = PageRequest.of(safePage, cappedSize, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<ElectionAuditLog> result = auditLogRepository.findWithFilters(
electionId, entityTypeParam, actionParam, searchParam, pageable);
List<ElectionAuditLogData> content = result.getContent()
.stream()
.map(this::toData)
.collect(Collectors.toList());
Map<String, Object> response = new LinkedHashMap<>();
response.put("content", content);
response.put("totalElements", result.getTotalElements());
response.put("totalPages", result.getTotalPages());
response.put("currentPage", result.getNumber());
response.put("pageSize", result.getSize());
return response;
}
@Override
@Transactional(readOnly = true)
public List<ElectionAuditLogData> getEntityAuditLog(String entityType, Long entityId) {
return auditLogRepository.findByEntityTypeAndEntityIdOrderByCreatedAtDesc(entityType, entityId)
.stream()
.map(this::toData)
.collect(Collectors.toList());
}
private ElectionAuditLogData toData(ElectionAuditLog log) {
return ElectionAuditLogData.builder()
.id(log.getId())
.electionId(log.getElectionId())
.entityType(log.getEntityType())
.entityId(log.getEntityId())
.action(log.getAction())
.performedById(log.getPerformedById())
.performedByName(log.getPerformedByName())
.officeId(log.getOfficeId())
.ipAddress(log.getIpAddress())
.details(log.getDetails())
.description(log.getDescription())
.createdAt(log.getCreatedAt() != null
? log.getCreatedAt().format(DISPLAY_FORMATTER)
: null)
.build();
}
}
@@ -368,4 +368,5 @@
<include file="parts/0338_election_ceo_precheck_extended_checks.xml" relativeToChangelogFile="true"/>
<include file="parts/0339_election_ceo_precheck_family_loan_check.xml" relativeToChangelogFile="true"/>
<include file="parts/0340_election_candidate_symbol_text.xml" relativeToChangelogFile="true"/>
<include file="parts/0341_election_audit_log.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0340-election-candidate-symbol-text" author="fineract">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_election_candidate" columnName="symbol"/>
</preConditions>
<modifyDataType tableName="m_election_candidate" columnName="symbol" newDataType="TEXT"/>
<comment>Expand symbol column to TEXT to support base64 image data URLs</comment>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0341-election-audit-log" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_election_audit_log"/></not>
</preConditions>
<createTable tableName="m_election_audit_log">
<column name="id" type="BIGSERIAL" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="election_id" type="BIGINT"/>
<column name="entity_type" type="VARCHAR(50)">
<constraints nullable="false"/>
</column>
<column name="entity_id" type="BIGINT"/>
<column name="action" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<column name="performed_by_id" type="BIGINT"/>
<column name="performed_by_name" type="VARCHAR(100)"/>
<column name="office_id" type="BIGINT"/>
<column name="ip_address" type="VARCHAR(50)"/>
<column name="details" type="TEXT"/>
<column name="description" type="VARCHAR(500)"/>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex indexName="idx_election_audit_election_created"
tableName="m_election_audit_log">
<column name="election_id"/>
<column name="created_at" descending="true"/>
</createIndex>
<createIndex indexName="idx_election_audit_entity"
tableName="m_election_audit_log">
<column name="entity_type"/>
<column name="entity_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>