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:
co-authored by
Claude Sonnet 4.6
parent
3d30bdb788
commit
69513f25f3
+33
-5
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+61
-7
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+42
-6
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+28
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -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;
|
||||
}
|
||||
+17
@@ -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
|
||||
}
|
||||
+68
@@ -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();
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
+133
@@ -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>
|
||||
|
||||
+15
@@ -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>
|
||||
+48
@@ -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>
|
||||
Reference in New Issue
Block a user