diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java index 4bb4ca876..1fd8f2ab6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java @@ -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 body) { + Map 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(); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java index 92f2d4919..665af662d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionConfigApiResource.java @@ -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(); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java index 88350715e..c270418b1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionNominationApiResource.java @@ -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(); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java index 8dc260847..0ea2e5952 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java @@ -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(); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionAuditLogData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionAuditLogData.java new file mode 100644 index 000000000..537141261 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionAuditLogData.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditAction.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditAction.java new file mode 100644 index 000000000..7712921cd --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditAction.java @@ -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 +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditLog.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditLog.java new file mode 100644 index 000000000..0a6a262ed --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditLog.java @@ -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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditLogRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditLogRepository.java new file mode 100644 index 000000000..ce7b304d9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionAuditLogRepository.java @@ -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 { + + List 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 findWithFilters( + @Param("electionId") Long electionId, + @Param("entityType") String entityType, + @Param("action") String action, + @Param("search") String search, + Pageable pageable); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAuditService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAuditService.java new file mode 100644 index 000000000..7b001257c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAuditService.java @@ -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 getAuditLogPaged(Long electionId, String entityType, + String action, String search, + int page, int size); + + List getEntityAuditLog(String entityType, Long entityId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAuditServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAuditServiceImpl.java new file mode 100644 index 000000000..509b33af1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionAuditServiceImpl.java @@ -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 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 result = auditLogRepository.findWithFilters( + electionId, entityTypeParam, actionParam, searchParam, pageable); + + List content = result.getContent() + .stream() + .map(this::toData) + .collect(Collectors.toList()); + + Map 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 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(); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 687367173..990c7e3dc 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -368,4 +368,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0340_election_candidate_symbol_text.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0340_election_candidate_symbol_text.xml new file mode 100644 index 000000000..d6c5ddf3b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0340_election_candidate_symbol_text.xml @@ -0,0 +1,15 @@ + + + + + + + + + Expand symbol column to TEXT to support base64 image data URLs + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0341_election_audit_log.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0341_election_audit_log.xml new file mode 100644 index 000000000..6b3369b16 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0341_election_audit_log.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +