From 99cc972804160261048ddc1e8a5a3e50a51a78f0 Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Wed, 1 Jul 2026 14:59:23 +0530 Subject: [PATCH] =?UTF-8?q?feat(election):=20comprehensive=20audit=20log?= =?UTF-8?q?=20=E2=80=94=20rich=20details=20for=20all=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed VARCHAR(500) overflow: description column changed to TEXT (Liquibase 0346) - Fixed CEO pre-check 500 error: fm.firstname/lastname column name fix - Fixed audit log showing wrong PASS/FAIL for CEO pre-check - Fixed approval action not showing step name/number (read PENDING step before action) - submitNomination: details JSON with applicantName, memberId, postId, gender, caste - runEligibilityCheck: details JSON with all 5 check results + remarks - runCeoPreCheck: details JSON with all 7 individual check results + remarks - processApprovalStep: details JSON with action, stepName, stepNumber, remarks, rejectionReason - activateElection/closeNominations/openVoting/closeVoting/cancelElection: details with electionTitle + newStatus - updateElection: full request JSON via ObjectMapper - approveCandidate: details with nominationId + applicantName - updateCandidate: details with symbolUpdated, profileUpdated, briefProfile - uploadCandidateSymbol: details with filename + mimeType - declareResults: winner summary JSON array [{post, winner, votes}] - saveManualVoteCounts: return type changed void→String, details JSON array with candidate vote data - Added Liquibase migrations: 0344, 0345 for manual vote count, 0346 for TEXT column Co-Authored-By: Claude Sonnet 4.6 --- .../api/ElectionCandidateApiResource.java | 43 ++++- .../api/ElectionConfigApiResource.java | 72 ++++++-- .../api/ElectionNominationApiResource.java | 163 ++++++++++++++++-- .../api/ElectionVotingApiResource.java | 118 ++++++++----- .../election/data/ElectionCandidateData.java | 1 + .../election/data/ManualVoteCountEntry.java | 18 ++ .../data/ManualVoteCountsRequest.java | 14 ++ .../election/domain/ElectionAuditAction.java | 13 +- .../election/domain/ElectionAuditLog.java | 2 +- .../election/domain/ElectionCandidate.java | 3 + ...oteCountExceedsShareAccountsException.java | 20 +++ .../VoteCountsNotSavedException.java | 12 ++ .../service/ElectionAuditService.java | 8 + .../service/ElectionAuditServiceImpl.java | 8 + .../ElectionCeoPreCheckServiceImpl.java | 2 +- .../ElectionReadPlatformServiceImpl.java | 1 + .../service/ElectionWritePlatformService.java | 12 ++ .../ElectionWritePlatformServiceImpl.java | 133 +++++++++++--- .../db/changelog/tenant/changelog-tenant.xml | 3 + ...4_election_candidate_manual_vote_count.xml | 18 ++ ...ndidate_manual_vote_count_drop_default.xml | 14 ++ ...election_audit_log_description_to_text.xml | 14 ++ 22 files changed, 586 insertions(+), 106 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ManualVoteCountEntry.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ManualVoteCountsRequest.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/VoteCountExceedsShareAccountsException.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/VoteCountsNotSavedException.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0344_election_candidate_manual_vote_count.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0345_election_candidate_manual_vote_count_drop_default.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0346_election_audit_log_description_to_text.xml 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 de58cbeb3..ebf007aaa 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 @@ -18,6 +18,7 @@ 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.data.ElectionNominationData; import org.apache.fineract.portfolio.election.domain.ElectionAuditAction; import org.apache.fineract.portfolio.election.service.ElectionAnalyticsService; import org.apache.fineract.portfolio.election.service.ElectionAuditService; @@ -68,9 +69,21 @@ public class ElectionCandidateApiResource { @PathParam("nominationId") Long nominationId, @Context HttpServletRequest httpRequest) { Long approvedBy = context.authenticatedUser().getId(); - Long candidateId = writeService.approveCandidate(nominationId, approvedBy); + ElectionNominationData nom = readService.retrieveNomination(nominationId); + String applicantName = nom != null ? nom.getApplicantName() : null; + Long candidateId; + try { + candidateId = writeService.approveCandidate(nominationId, approvedBy); + } catch (RuntimeException e) { + electionAuditService.logInNewTransaction(ElectionAuditAction.APPROVE_CANDIDATE_FAILED, + "CANDIDATE", nominationId, electionId, + "Candidate approval failed for nomination " + nominationId + ": " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } + String appDet = "{\"nominationId\":" + nominationId + ",\"applicantName\":" + esc(applicantName) + "}"; electionAuditService.log(ElectionAuditAction.APPROVE_CANDIDATE, "CANDIDATE", candidateId, electionId, - "Nomination approved as candidate", null, clientIp(httpRequest)); + "Nomination #" + nominationId + " approved — " + (applicantName != null ? applicantName : "candidate #" + candidateId), + appDet, clientIp(httpRequest)); return Response.ok(Map.of("resourceId", candidateId)).build(); } @@ -81,9 +94,14 @@ public class ElectionCandidateApiResource { @PathParam("candidateId") Long candidateId, Map body, @Context HttpServletRequest httpRequest) { - writeService.updateCandidate(candidateId, body.get("symbol"), body.get("briefProfile")); + String symbol = body.get("symbol"); + String briefProfile = body.get("briefProfile"); + writeService.updateCandidate(candidateId, symbol, briefProfile); + String updatedFields = (symbol != null ? "symbol" : "") + (symbol != null && briefProfile != null ? ", " : "") + (briefProfile != null ? "profile" : ""); + String updDet = "{\"symbolUpdated\":" + (symbol != null) + ",\"profileUpdated\":" + (briefProfile != null) + + (briefProfile != null ? ",\"briefProfile\":" + esc(briefProfile) : "") + "}"; electionAuditService.log(ElectionAuditAction.UPDATE_CANDIDATE, "CANDIDATE", candidateId, electionId, - "Candidate updated", null, clientIp(httpRequest)); + "Candidate updated" + (!updatedFields.isBlank() ? ": " + updatedFields : ""), updDet, clientIp(httpRequest)); return Response.ok(Map.of("resourceId", candidateId)).build(); } @@ -118,8 +136,10 @@ 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); + String filename = fileDetail != null ? fileDetail.getFileName() : null; + String symDet = "{\"filename\":" + esc(filename) + ",\"mimeType\":" + esc(mimeType) + "}"; electionAuditService.log(ElectionAuditAction.UPLOAD_SYMBOL, "CANDIDATE", candidateId, electionId, - "Election symbol uploaded", null, clientIp(httpRequest)); + "Election symbol uploaded" + (filename != null ? ": " + filename : ""), symDet, clientIp(httpRequest)); return Response.ok(Map.of("resourceId", candidateId)).build(); } @@ -164,7 +184,13 @@ public class ElectionCandidateApiResource { @PathParam("candidateId") Long candidateId, @QueryParam("reason") String reason, @Context HttpServletRequest httpRequest) { - writeService.removeCandidate(candidateId, reason); + try { + writeService.removeCandidate(candidateId, reason); + } catch (RuntimeException e) { + electionAuditService.logInNewTransaction(ElectionAuditAction.REMOVE_CANDIDATE_FAILED, + "CANDIDATE", candidateId, electionId, "Remove candidate failed: " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } electionAuditService.log(ElectionAuditAction.REMOVE_CANDIDATE, "CANDIDATE", candidateId, electionId, "Candidate removed: " + reason, null, clientIp(httpRequest)); return Response.ok(Map.of("resourceId", candidateId, "status", "REMOVED")).build(); @@ -195,4 +221,9 @@ public class ElectionCandidateApiResource { if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim(); return req.getRemoteAddr(); } + + private static String esc(String s) { + if (s == null) return "null"; + return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\""; + } } 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 665af662d..6239546c9 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 @@ -18,6 +18,7 @@ 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.ElectionConfigData; import org.apache.fineract.portfolio.election.data.ElectionConfigRequest; import org.apache.fineract.portfolio.election.domain.ElectionAuditAction; import org.apache.fineract.portfolio.election.service.ElectionAuditService; @@ -74,8 +75,10 @@ public class ElectionConfigApiResource { ElectionConfigRequest request, @Context HttpServletRequest httpRequest) { writeService.updateElection(electionId, request); + String updateDetails = null; + try { updateDetails = objectMapper.writeValueAsString(request); } catch (Exception ignored) {} electionAuditService.log(ElectionAuditAction.UPDATE_ELECTION, "ELECTION", electionId, electionId, - "Election updated", null, clientIp(httpRequest)); + "Election updated: " + request.getTitle(), updateDetails, clientIp(httpRequest)); return Response.ok(Map.of("resourceId", electionId)).build(); } @@ -84,9 +87,17 @@ public class ElectionConfigApiResource { @Operation(summary = "Activate election to open nominations") public Response activateElection(@PathParam("electionId") Long electionId, @Context HttpServletRequest httpRequest) { - writeService.activateElection(electionId); + ElectionConfigData elec = readService.retrieveElection(electionId); + try { + writeService.activateElection(electionId); + } catch (RuntimeException e) { + electionAuditService.logInNewTransaction(ElectionAuditAction.ACTIVATE_ELECTION_FAILED, + "ELECTION", electionId, electionId, "Activate failed: " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } + String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null) + ",\"newStatus\":\"ACTIVE\"}"; electionAuditService.log(ElectionAuditAction.ACTIVATE_ELECTION, "ELECTION", electionId, electionId, - "Election activated", null, clientIp(httpRequest)); + "Election activated — " + (elec != null ? elec.getTitle() : electionId), det, clientIp(httpRequest)); return Response.ok(Map.of("resourceId", electionId, "status", "ACTIVE")).build(); } @@ -95,9 +106,17 @@ public class ElectionConfigApiResource { @Operation(summary = "Close nomination window") public Response closeNominations(@PathParam("electionId") Long electionId, @Context HttpServletRequest httpRequest) { - writeService.closeNominations(electionId); + ElectionConfigData elec = readService.retrieveElection(electionId); + try { + writeService.closeNominations(electionId); + } catch (RuntimeException e) { + electionAuditService.logInNewTransaction(ElectionAuditAction.CLOSE_NOMINATIONS_FAILED, + "ELECTION", electionId, electionId, "Close nominations failed: " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } + String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null) + ",\"newStatus\":\"NOMINATION_CLOSED\"}"; electionAuditService.log(ElectionAuditAction.CLOSE_NOMINATIONS, "ELECTION", electionId, electionId, - "Nominations closed", null, clientIp(httpRequest)); + "Nominations closed — " + (elec != null ? elec.getTitle() : electionId), det, clientIp(httpRequest)); return Response.ok(Map.of("resourceId", electionId, "status", "NOMINATION_CLOSED")).build(); } @@ -106,9 +125,17 @@ public class ElectionConfigApiResource { @Operation(summary = "Open voting") public Response openVoting(@PathParam("electionId") Long electionId, @Context HttpServletRequest httpRequest) { - writeService.openVoting(electionId); + ElectionConfigData elec = readService.retrieveElection(electionId); + try { + writeService.openVoting(electionId); + } catch (RuntimeException e) { + electionAuditService.logInNewTransaction(ElectionAuditAction.OPEN_VOTING_FAILED, + "ELECTION", electionId, electionId, "Open voting failed: " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } + String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null) + ",\"newStatus\":\"VOTING_OPEN\"}"; electionAuditService.log(ElectionAuditAction.OPEN_VOTING, "ELECTION", electionId, electionId, - "Voting opened", null, clientIp(httpRequest)); + "Voting opened — " + (elec != null ? elec.getTitle() : electionId), det, clientIp(httpRequest)); return Response.ok(Map.of("resourceId", electionId, "status", "VOTING_OPEN")).build(); } @@ -117,9 +144,17 @@ public class ElectionConfigApiResource { @Operation(summary = "Close voting") public Response closeVoting(@PathParam("electionId") Long electionId, @Context HttpServletRequest httpRequest) { - writeService.closeVoting(electionId); + ElectionConfigData elec = readService.retrieveElection(electionId); + try { + writeService.closeVoting(electionId); + } catch (RuntimeException e) { + electionAuditService.logInNewTransaction(ElectionAuditAction.CLOSE_VOTING_FAILED, + "ELECTION", electionId, electionId, "Close voting failed: " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } + String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null) + ",\"newStatus\":\"VOTING_CLOSED\"}"; electionAuditService.log(ElectionAuditAction.CLOSE_VOTING, "ELECTION", electionId, electionId, - "Voting closed", null, clientIp(httpRequest)); + "Voting closed — " + (elec != null ? elec.getTitle() : electionId), det, clientIp(httpRequest)); return Response.ok(Map.of("resourceId", electionId, "status", "VOTING_CLOSED")).build(); } @@ -129,9 +164,19 @@ public class ElectionConfigApiResource { public Response cancelElection(@PathParam("electionId") Long electionId, @QueryParam("reason") String reason, @Context HttpServletRequest httpRequest) { - writeService.cancelElection(electionId, reason); + ElectionConfigData elec = readService.retrieveElection(electionId); + try { + writeService.cancelElection(electionId, reason); + } catch (RuntimeException e) { + electionAuditService.logInNewTransaction(ElectionAuditAction.CANCEL_ELECTION_FAILED, + "ELECTION", electionId, electionId, "Cancel failed: " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } + String det = "{\"electionTitle\":" + esc(elec != null ? elec.getTitle() : null) + + ",\"newStatus\":\"CANCELLED\"" + + (reason != null ? ",\"reason\":" + esc(reason) : "") + "}"; electionAuditService.log(ElectionAuditAction.CANCEL_ELECTION, "ELECTION", electionId, electionId, - "Election cancelled: " + reason, null, clientIp(httpRequest)); + "Election cancelled: " + reason, det, clientIp(httpRequest)); return Response.ok(Map.of("resourceId", electionId, "status", "CANCELLED")).build(); } @@ -159,4 +204,9 @@ public class ElectionConfigApiResource { if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim(); return req.getRemoteAddr(); } + + private static String esc(String s) { + if (s == null) return "null"; + return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\""; + } } 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 c270418b1..c80986a19 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 @@ -15,6 +15,8 @@ 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.ElectionApprovalStepData; +import org.apache.fineract.portfolio.election.data.ElectionNominationData; import org.apache.fineract.portfolio.election.data.ElectionNominationRequest; import org.apache.fineract.portfolio.election.domain.ElectionAuditAction; import org.apache.fineract.portfolio.election.service.ElectionAuditService; @@ -43,11 +45,24 @@ public class ElectionNominationApiResource { ElectionNominationRequest request, @Context HttpServletRequest httpRequest) { request.setElectionId(electionId); - Long id = writeService.submitNomination(request); + Long id; + try { + id = writeService.submitNomination(request); + } catch (RuntimeException e) { + electionAuditService.logInNewTransaction(ElectionAuditAction.SUBMIT_NOMINATION_FAILED, + "NOMINATION", null, electionId, + "Nomination submit failed for member " + request.getMemberId() + ": " + e.getMessage(), + null, clientIp(httpRequest)); + throw e; + } + String subDetails = "{\"applicantName\":" + esc(request.getApplicantName()) + + ",\"memberId\":" + request.getMemberId() + + ",\"postId\":" + request.getPostId() + + ",\"gender\":" + esc(request.getGender()) + + ",\"caste\":" + esc(request.getCaste()) + + "}"; electionAuditService.log(ElectionAuditAction.SUBMIT_NOMINATION, "NOMINATION", id, electionId, - "Nomination submitted by member " + request.getMemberId(), - null, - clientIp(httpRequest)); + "Nomination submitted — " + request.getApplicantName(), subDetails, clientIp(httpRequest)); return Response.ok(Map.of("resourceId", id)).build(); } @@ -75,7 +90,13 @@ public class ElectionNominationApiResource { public Response withdrawNomination(@PathParam("electionId") Long electionId, @PathParam("nominationId") Long nominationId, @Context HttpServletRequest httpRequest) { - writeService.withdrawNomination(nominationId); + try { + writeService.withdrawNomination(nominationId); + } catch (RuntimeException e) { + electionAuditService.logInNewTransaction(ElectionAuditAction.WITHDRAW_NOMINATION_FAILED, + "NOMINATION", nominationId, electionId, "Withdraw failed: " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } electionAuditService.log(ElectionAuditAction.WITHDRAW_NOMINATION, "NOMINATION", nominationId, electionId, "Nomination withdrawn", null, clientIp(httpRequest)); return Response.ok(Map.of("resourceId", nominationId, "status", "WITHDRAWN")).build(); @@ -99,10 +120,40 @@ public class ElectionNominationApiResource { public Response runEligibilityCheck(@PathParam("electionId") Long electionId, @PathParam("nominationId") Long nominationId, @Context HttpServletRequest httpRequest) { - writeService.runEligibilityCheck(nominationId); + try { + writeService.runEligibilityCheck(nominationId); + } catch (RuntimeException e) { + electionAuditService.logInNewTransaction(ElectionAuditAction.ELIGIBILITY_CHECK_FAILED, + "NOMINATION", nominationId, electionId, "Eligibility check failed: " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } + ElectionNominationData eligData = readService.retrieveNomination(nominationId); + String eligResult = eligData.getEligibilityCheck() != null + ? eligData.getEligibilityCheck().getOverallResult() : null; + String eligDesc = "FAIL".equals(eligResult) + ? "Eligibility check failed — " + (eligData.getRejectionReason() != null ? eligData.getRejectionReason() : "not eligible") + : "Eligibility check passed"; + String eligDetails = null; + if (eligData.getEligibilityCheck() != null) { + var ec = eligData.getEligibilityCheck(); + StringBuilder det = new StringBuilder("{"); + det.append("\"overallResult\":").append(esc(ec.getOverallResult())); + det.append(",\"age\":").append(esc(ec.getAgeEligible() != null ? (ec.getAgeEligible() ? "PASS" : "FAIL") : null)); + det.append(",\"gender\":").append(esc(ec.getGenderEligible() != null ? (ec.getGenderEligible() ? "PASS" : "FAIL") : null)); + det.append(",\"caste\":").append(esc(ec.getCasteEligible() != null ? (ec.getCasteEligible() ? "PASS" : "FAIL") : null)); + det.append(",\"shareHolding\":").append(esc(ec.getShareHoldingEligible() != null ? (ec.getShareHoldingEligible() ? "PASS" : "FAIL") : null)); + det.append(",\"area\":").append(esc(ec.getAreaEligible() != null ? (ec.getAreaEligible() ? "PASS" : "FAIL") : null)); + if (ec.getAgeCheckRemarks() != null) det.append(",\"ageRemarks\":").append(esc(ec.getAgeCheckRemarks())); + if (ec.getGenderCheckRemarks() != null) det.append(",\"genderRemarks\":").append(esc(ec.getGenderCheckRemarks())); + if (ec.getCasteCheckRemarks() != null) det.append(",\"casteRemarks\":").append(esc(ec.getCasteCheckRemarks())); + if (ec.getShareCheckRemarks() != null) det.append(",\"shareRemarks\":").append(esc(ec.getShareCheckRemarks())); + if (ec.getAreaCheckRemarks() != null) det.append(",\"areaRemarks\":").append(esc(ec.getAreaCheckRemarks())); + det.append("}"); + eligDetails = det.toString(); + } electionAuditService.log(ElectionAuditAction.ELIGIBILITY_CHECK, "NOMINATION", nominationId, electionId, - "Eligibility check run", null, clientIp(httpRequest)); - return Response.ok(readService.retrieveNomination(nominationId)).build(); + eligDesc, eligDetails, clientIp(httpRequest)); + return Response.ok(eligData).build(); } @POST @@ -111,18 +162,60 @@ public class ElectionNominationApiResource { public Response runCeoPreCheck(@PathParam("electionId") Long electionId, @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(); + try { + writeService.runCeoPreCheck(nominationId); + } catch (RuntimeException e) { + electionAuditService.logInNewTransaction(ElectionAuditAction.CEO_PRECHECK_FAILED, + "NOMINATION", nominationId, electionId, "CEO pre-check failed: " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } + ElectionNominationData nominationData = readService.retrieveNomination(nominationId); + boolean ceoFailed = nominationData.getCeoPreCheck() != null + && "FAIL".equals(nominationData.getCeoPreCheck().getOverallResult()); + String ceoDetails = null; + if (nominationData.getCeoPreCheck() != null) { + var cp = nominationData.getCeoPreCheck(); + StringBuilder det = new StringBuilder("{"); + det.append("\"directorLoan\":").append(esc(cp.getDirectorLoanCheckResult())); + if (cp.getDirectorLoanOutstanding() != null) det.append(",\"directorLoanOutstanding\":").append(cp.getDirectorLoanOutstanding()); + if (cp.getDirectorLoanRemarks() != null) det.append(",\"directorLoanRemarks\":").append(esc(cp.getDirectorLoanRemarks())); + det.append(",\"relationAccount\":").append(esc(cp.getRelationAcCheckResult())); + if (cp.getRelationAcRemarks() != null) det.append(",\"relationRemarks\":").append(esc(cp.getRelationAcRemarks())); + det.append(",\"kyc\":").append(esc(cp.getKycCheckResult())); + if (cp.getKycCheckRemarks() != null) det.append(",\"kycRemarks\":").append(esc(cp.getKycCheckRemarks())); + det.append(",\"familyKyc\":").append(esc(cp.getFamilyKycCheckResult())); + if (cp.getFamilyKycCheckRemarks() != null) det.append(",\"familyKycRemarks\":").append(esc(cp.getFamilyKycCheckRemarks())); + det.append(",\"guarantor\":").append(esc(cp.getGuarantorCheckResult())); + if (cp.getGuarantorCheckRemarks() != null) det.append(",\"guarantorRemarks\":").append(esc(cp.getGuarantorCheckRemarks())); + det.append(",\"aml\":").append(esc(cp.getAmlCheckResult())); + if (cp.getAmlCheckRemarks() != null) det.append(",\"amlRemarks\":").append(esc(cp.getAmlCheckRemarks())); + det.append(",\"familyLoan\":").append(esc(cp.getFamilyLoanCheckResult())); + if (cp.getFamilyLoanCheckRemarks() != null) det.append(",\"familyLoanRemarks\":").append(esc(cp.getFamilyLoanCheckRemarks())); + det.append("}"); + ceoDetails = det.toString(); + } + if (ceoFailed) { + String reason = nominationData.getRejectionReason() != null + ? nominationData.getRejectionReason() : "checks did not pass"; + electionAuditService.logInNewTransaction(ElectionAuditAction.CEO_PRECHECK_FAILED, + "NOMINATION", nominationId, electionId, + "CEO pre-check failed — " + reason, ceoDetails, clientIp(httpRequest)); + } else { + electionAuditService.log(ElectionAuditAction.CEO_PRECHECK, "NOMINATION", nominationId, electionId, + "CEO pre-check passed", ceoDetails, clientIp(httpRequest)); + } + return Response.ok(nominationData).build(); } @POST @Path("/{electionId}/nomination/{nominationId}/no-due-certificate") @Operation(summary = "Generate No Due Certificate for nominee") public Response generateNoDueCertificate(@PathParam("electionId") Long electionId, - @PathParam("nominationId") Long nominationId) { + @PathParam("nominationId") Long nominationId, + @Context HttpServletRequest httpRequest) { String certNumber = writeService.generateNoDueCertificate(nominationId); + electionAuditService.log(ElectionAuditAction.GENERATE_NO_DUE_CERTIFICATE, "NOMINATION", nominationId, electionId, + "No due certificate generated: " + certNumber, null, clientIp(httpRequest)); return Response.ok(Map.of("certificateNumber", certNumber)).build(); } @@ -133,9 +226,44 @@ public class ElectionNominationApiResource { @PathParam("nominationId") Long nominationId, ElectionApprovalActionRequest request, @Context HttpServletRequest httpRequest) { - writeService.processApprovalStep(nominationId, request); + // Capture the current PENDING step BEFORE processing — status changes to APPROVED/REJECTED after + ElectionNominationData beforeData = readService.retrieveNomination(nominationId); + ElectionApprovalStepData pendingStep = beforeData.getApprovalSteps() != null + ? beforeData.getApprovalSteps().stream() + .filter(s -> "PENDING".equalsIgnoreCase(s.getStatus())) + .findFirst().orElse(null) + : null; + try { + writeService.processApprovalStep(nominationId, request); + } catch (RuntimeException e) { + electionAuditService.logInNewTransaction(ElectionAuditAction.APPROVAL_ACTION_FAILED, + "NOMINATION", nominationId, electionId, + "Approval action failed (" + request.getAction() + "): " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } + StringBuilder approvalDesc = new StringBuilder("Approval action: ").append(request.getAction()); + StringBuilder approvalDet = new StringBuilder("{\"action\":").append(esc(request.getAction())); + if (pendingStep != null) { + if (pendingStep.getStepName() != null) { + approvalDesc.append(" — Step: ").append(pendingStep.getStepName()); + approvalDet.append(",\"stepName\":").append(esc(pendingStep.getStepName())); + } + if (pendingStep.getStepNumber() != null) { + approvalDesc.append(" (#").append(pendingStep.getStepNumber()).append(")"); + approvalDet.append(",\"stepNumber\":").append(pendingStep.getStepNumber()); + } + } + if (request.getRemarks() != null && !request.getRemarks().isBlank()) { + approvalDesc.append(" — Remarks: ").append(request.getRemarks()); + approvalDet.append(",\"remarks\":").append(esc(request.getRemarks())); + } + if ("REJECT".equalsIgnoreCase(request.getAction()) && request.getRejectionReason() != null && !request.getRejectionReason().isBlank()) { + approvalDesc.append(" — Reason: ").append(request.getRejectionReason()); + approvalDet.append(",\"rejectionReason\":").append(esc(request.getRejectionReason())); + } + approvalDet.append("}"); electionAuditService.log(ElectionAuditAction.APPROVAL_ACTION, "NOMINATION", nominationId, electionId, - "Approval action: " + request.getAction(), null, clientIp(httpRequest)); + approvalDesc.toString(), approvalDet.toString(), clientIp(httpRequest)); return Response.ok(readService.retrieveNomination(nominationId)).build(); } @@ -156,4 +284,9 @@ public class ElectionNominationApiResource { if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim(); return req.getRemoteAddr(); } + + private static String esc(String s) { + if (s == null) return "null"; + return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\""; + } } 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 0ea2e5952..e2494d9de 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 @@ -1,3 +1,10 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ package org.apache.fineract.portfolio.election.api; import io.swagger.v3.oas.annotations.Operation; @@ -14,15 +21,16 @@ 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.data.ElectionResultData; +import org.apache.fineract.portfolio.election.data.ManualVoteCountEntry; +import org.apache.fineract.portfolio.election.data.ManualVoteCountsRequest; import org.apache.fineract.portfolio.election.domain.ElectionAuditAction; -import org.apache.fineract.portfolio.election.domain.ElectionVoteRepository; +import org.apache.fineract.portfolio.election.exception.VoteCountExceedsShareAccountsException; 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; -import java.util.HashMap; import java.util.List; import java.util.Map; @@ -37,36 +45,73 @@ public class ElectionVotingApiResource { private final ElectionReadPlatformService readService; 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, - @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(); + @Path("/{electionId}/manual-vote-counts") + @Operation(summary = "Save manual vote counts for all candidates after physical voting") + public Response saveManualVoteCounts(@PathParam("electionId") Long electionId, + ManualVoteCountsRequest request, + @Context HttpServletRequest httpRequest) { + List counts = request.getCounts() != null ? request.getCounts() : List.of(); + String reason = request.getReason(); + String voteDetails; + try { + voteDetails = writeService.saveManualVoteCounts(electionId, counts, reason); + } catch (VoteCountExceedsShareAccountsException e) { + electionAuditService.logInNewTransaction( + ElectionAuditAction.MANUAL_VOTE_ENTRY_FAILED, "VOTE", electionId, electionId, + "Vote count validation failed: " + e.getDefaultUserMessage(), + null, clientIp(httpRequest)); + throw e; + } + boolean isEdit = voteDetails.contains("\"previousVotes\""); + String auditMsg = (isEdit ? "Vote counts updated" : "Vote counts entered") + + " for " + counts.size() + " candidates" + + (reason != null && !reason.isBlank() ? " — Reason: " + reason : ""); + electionAuditService.log(ElectionAuditAction.MANUAL_VOTE_ENTRY, "VOTE", electionId, electionId, + auditMsg, voteDetails, clientIp(httpRequest)); + return Response.ok(Map.of("message", "Manual vote counts saved successfully")).build(); } @POST @Path("/{electionId}/declare-results") - @Operation(summary = "Declare election results") + @Operation(summary = "Declare election results using saved manual vote counts") public Response declareResults(@PathParam("electionId") Long electionId, @Context HttpServletRequest httpRequest) { Long declaredBy = context.authenticatedUser().getId(); - writeService.declareResults(electionId, declaredBy); + try { + writeService.declareResults(electionId, declaredBy); + } catch (RuntimeException e) { + electionAuditService.logInNewTransaction(ElectionAuditAction.DECLARE_RESULTS_FAILED, + "RESULT", electionId, electionId, "Declare results failed: " + e.getMessage(), null, clientIp(httpRequest)); + throw e; + } + java.util.List results = readService.retrieveResultsByElection(electionId); + // Build winner summary for audit details + StringBuilder winDet = new StringBuilder("["); + boolean wFirst = true; + if (results != null) { + for (ElectionResultData r : results) { + if (Integer.valueOf(1).equals(r.getRankPosition()) || "WON".equalsIgnoreCase(r.getResult())) { + if (!wFirst) winDet.append(","); + winDet.append("{\"post\":").append(esc(r.getPostName())) + .append(",\"winner\":").append(esc(r.getCandidateName())) + .append(",\"votes\":").append(r.getVoteCount() != null ? r.getVoteCount() : 0) + .append("}"); + wFirst = false; + } + } + } + winDet.append("]"); + String declareDetails = wFirst ? null : winDet.toString(); + String declareDesc = "Results declared" + (declareDetails != null ? " — " + (results.stream() + .filter(r -> Integer.valueOf(1).equals(r.getRankPosition()) || "WON".equalsIgnoreCase(r.getResult())) + .map(r -> r.getCandidateName() + " won " + r.getPostName()) + .reduce((a, b) -> a + "; " + b).orElse("")) : ""); electionAuditService.log(ElectionAuditAction.DECLARE_RESULTS, "RESULT", electionId, electionId, - "Results declared", null, clientIp(httpRequest)); - return Response.ok(readService.retrieveResultsByElection(electionId)).build(); + declareDesc, declareDetails, clientIp(httpRequest)); + return Response.ok(results).build(); } @GET @@ -76,30 +121,6 @@ public class ElectionVotingApiResource { return Response.ok(readService.retrieveResultsByElection(electionId)).build(); } - @GET - @Path("/{electionId}/vote-summary") - @Operation(summary = "Get live vote counts per candidate per post") - public Response getVoteSummary(@PathParam("electionId") Long electionId) { - long totalVotes = voteRepository.countByElectionConfigId(electionId); - long totalVoters = voteRepository.countTotalVotersByElection(electionId); - List rows = voteRepository.voteSummaryByCandidate(electionId); - - List> breakdown = rows.stream().map(r -> { - Map entry = new HashMap<>(); - entry.put("postId", r[0]); - entry.put("candidateId", r[1]); - entry.put("voteCount", r[2]); - return entry; - }).collect(java.util.stream.Collectors.toList()); - - Map summary = new HashMap<>(); - summary.put("electionId", electionId); - summary.put("totalVotes", totalVotes); - summary.put("totalVoters", totalVoters); - summary.put("breakdown", breakdown); - return Response.ok(summary).build(); - } - // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- @@ -110,4 +131,9 @@ public class ElectionVotingApiResource { if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim(); return req.getRemoteAddr(); } + + private static String esc(String s) { + if (s == null) return "null"; + return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + "\""; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionCandidateData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionCandidateData.java index 1b02927c5..a7c95d91c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionCandidateData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionCandidateData.java @@ -31,4 +31,5 @@ public class ElectionCandidateData { private String location; private Integer shareCount; private Integer voteCount; + private Integer manualVoteCount; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ManualVoteCountEntry.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ManualVoteCountEntry.java new file mode 100644 index 000000000..65d8e0dec --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ManualVoteCountEntry.java @@ -0,0 +1,18 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.election.data; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class ManualVoteCountEntry { + private Long candidateId; + private Integer voteCount; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ManualVoteCountsRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ManualVoteCountsRequest.java new file mode 100644 index 000000000..4d77c82df --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ManualVoteCountsRequest.java @@ -0,0 +1,14 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +public class ManualVoteCountsRequest { + + private List counts; + private String reason; +} 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 index 7712921cd..3fcfc6b15 100644 --- 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 @@ -5,13 +5,22 @@ public enum ElectionAuditAction { CREATE_ELECTION, UPDATE_ELECTION, ACTIVATE_ELECTION, CLOSE_NOMINATIONS, OPEN_VOTING, CLOSE_VOTING, CANCEL_ELECTION, CREATE_POST, UPDATE_POST, + // Election Config — Failed + ACTIVATE_ELECTION_FAILED, CLOSE_NOMINATIONS_FAILED, + OPEN_VOTING_FAILED, CLOSE_VOTING_FAILED, CANCEL_ELECTION_FAILED, // Nomination SUBMIT_NOMINATION, WITHDRAW_NOMINATION, RESUBMIT_NOMINATION, ELIGIBILITY_CHECK, CEO_PRECHECK, APPROVAL_ACTION, + GENERATE_NO_DUE_CERTIFICATE, + // Nomination — Failed + SUBMIT_NOMINATION_FAILED, WITHDRAW_NOMINATION_FAILED, + ELIGIBILITY_CHECK_FAILED, CEO_PRECHECK_FAILED, APPROVAL_ACTION_FAILED, // Candidate APPROVE_CANDIDATE, REMOVE_CANDIDATE, UPLOAD_SYMBOL, UPDATE_CANDIDATE, + // Candidate — Failed + APPROVE_CANDIDATE_FAILED, REMOVE_CANDIDATE_FAILED, // Voting - CAST_VOTE, + CAST_VOTE, MANUAL_VOTE_ENTRY, MANUAL_VOTE_ENTRY_FAILED, // Results - DECLARE_RESULTS + DECLARE_RESULTS, DECLARE_RESULTS_FAILED } 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 index 0a6a262ed..2312901ef 100644 --- 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 @@ -55,7 +55,7 @@ public class ElectionAuditLog { @Column(name = "details", columnDefinition = "TEXT") private String details; - @Column(name = "description", length = 500) + @Column(name = "description", columnDefinition = "TEXT") private String description; @Column(name = "created_at", nullable = false) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java index cbab875d6..2e34798f9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java @@ -51,6 +51,9 @@ public class ElectionCandidate extends AbstractPersistableCustom { @Column(name = "brief_profile", columnDefinition = "TEXT") private String briefProfile; + @Column(name = "manual_vote_count") + private Integer manualVoteCount; + public static ElectionCandidate create(ElectionConfig electionConfig, ElectionNomination nomination, ElectionPost electionPost, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/VoteCountExceedsShareAccountsException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/VoteCountExceedsShareAccountsException.java new file mode 100644 index 000000000..b72d06f21 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/VoteCountExceedsShareAccountsException.java @@ -0,0 +1,20 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.election.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class VoteCountExceedsShareAccountsException extends AbstractPlatformDomainRuleException { + + public VoteCountExceedsShareAccountsException(String postName, int totalVotes, int activeShareAccounts) { + super("error.msg.election.vote.count.exceeds.share.accounts", + "Total votes for post '" + postName + "' is " + totalVotes + + ", which exceeds the number of active share accounts (" + activeShareAccounts + + "). Votes cannot exceed the total number of eligible voters."); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/VoteCountsNotSavedException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/VoteCountsNotSavedException.java new file mode 100644 index 000000000..1a56076b7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/exception/VoteCountsNotSavedException.java @@ -0,0 +1,12 @@ +package org.apache.fineract.portfolio.election.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class VoteCountsNotSavedException extends AbstractPlatformDomainRuleException { + + public VoteCountsNotSavedException(Long electionId, String postName) { + super("error.msg.election.vote.counts.not.saved", + "Manual vote counts have not been saved for post '" + postName + "' in election " + electionId + + ". Please enter and save vote counts on the Results page before closing voting."); + } +} 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 index 7b001257c..21b6e97ef 100644 --- 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 @@ -15,6 +15,14 @@ public interface ElectionAuditService { void log(ElectionAuditAction action, String entityType, Long entityId, Long electionId, String description, String details, String ipAddress); + /** + * Same as {@link #log} but always runs in a brand-new transaction so the + * entry is committed even if the caller's transaction rolls back (e.g. on + * validation failure). + */ + void logInNewTransaction(ElectionAuditAction action, String entityType, Long entityId, + Long electionId, String description, String details, String ipAddress); + /** * Server-side filtered + paginated audit log for an election. * 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 index 509b33af1..532aae6d6 100644 --- 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 @@ -8,6 +8,7 @@ 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.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.data.domain.Page; @@ -73,6 +74,13 @@ public class ElectionAuditServiceImpl implements ElectionAuditService { } } + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void logInNewTransaction(ElectionAuditAction action, String entityType, Long entityId, + Long electionId, String description, String details, String ipAddress) { + log(action, entityType, entityId, electionId, description, details, ipAddress); + } + @Override @Transactional(readOnly = true) public Map getAuditLogPaged(Long electionId, String entityType, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java index cfe839be3..213c566e0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionCeoPreCheckServiceImpl.java @@ -279,7 +279,7 @@ public class ElectionCeoPreCheckServiceImpl implements ElectionCeoPreCheckServic // Manual (unlinked) family members — loan status cannot be verified in CBS String manualQuery = """ - SELECT TRIM(CONCAT(COALESCE(fm.first_name, ''), ' ', COALESCE(fm.last_name, ''))) AS full_name + SELECT TRIM(CONCAT(COALESCE(fm.firstname, ''), ' ', COALESCE(fm.lastname, ''))) AS full_name FROM m_family_members fm WHERE fm.client_id = ? AND fm.linked_client_id IS NULL diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java index 09206d276..faee556af 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java @@ -250,6 +250,7 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ .approvedDate(c.getApprovedDate()) .hasSymbol(c.getSymbol() != null) .briefProfile(c.getBriefProfile()) + .manualVoteCount(c.getManualVoteCount()) .build()) .collect(Collectors.toList())) .pageNumber(result.getNumber()) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformService.java index b6ea25a49..d3ade0092 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformService.java @@ -1,9 +1,19 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ package org.apache.fineract.portfolio.election.service; import org.apache.fineract.portfolio.election.data.ElectionApprovalActionRequest; import org.apache.fineract.portfolio.election.data.ElectionConfigRequest; import org.apache.fineract.portfolio.election.data.ElectionNominationRequest; import org.apache.fineract.portfolio.election.data.ElectionVoteRequest; +import org.apache.fineract.portfolio.election.data.ManualVoteCountEntry; + +import java.util.List; public interface ElectionWritePlatformService { @@ -45,5 +55,7 @@ public interface ElectionWritePlatformService { void castVote(ElectionVoteRequest request); + String saveManualVoteCounts(Long electionId, List counts, String reason); + void declareResults(Long electionId, Long declaredBy); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java index cc0992641..b8ab35499 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java @@ -8,6 +8,7 @@ import org.apache.fineract.portfolio.election.data.ElectionConfigRequest; import org.apache.fineract.portfolio.election.data.ElectionNominationRequest; import org.apache.fineract.portfolio.election.data.ElectionPostRequest; import org.apache.fineract.portfolio.election.data.ElectionVoteRequest; +import org.apache.fineract.portfolio.election.data.ManualVoteCountEntry; import org.apache.fineract.portfolio.election.domain.ApprovalStepStatus; import org.apache.fineract.portfolio.election.domain.ElectionApprovalStep; import org.apache.fineract.portfolio.election.domain.ElectionApprovalStepRepository; @@ -34,11 +35,14 @@ import org.apache.fineract.portfolio.election.exception.ElectionNotActiveExcepti import org.apache.fineract.portfolio.election.exception.InvalidNominationStateException; import org.apache.fineract.portfolio.election.exception.NominationAlreadyExistsException; import org.apache.fineract.portfolio.election.exception.VoteAlreadyCastException; +import org.apache.fineract.portfolio.election.exception.VoteCountExceedsShareAccountsException; +import org.apache.fineract.portfolio.election.exception.VoteCountsNotSavedException; import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -158,6 +162,19 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe context.authenticatedUser().validateHasPermissionTo("UPDATE_ELECTION"); ElectionConfig election = electionConfigRepository.findById(electionId) .orElseThrow(() -> new ElectionNotFoundException(electionId)); + + List posts = electionPostRepository.findByElectionConfigIdAndIsActiveTrue(electionId); + for (ElectionPost post : posts) { + List candidates = candidateRepository + .findByElectionConfigIdAndElectionPostIdAndIsActiveTrue(electionId, post.getId()); + if (candidates.size() > 1) { + boolean countsEntered = candidates.stream().anyMatch(c -> c.getManualVoteCount() != null); + if (!countsEntered) { + throw new VoteCountsNotSavedException(electionId, post.getPostName()); + } + } + } + election.closeVoting(); electionConfigRepository.save(election); } @@ -431,27 +448,70 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe @Override @Transactional public void castVote(ElectionVoteRequest request) { - ElectionConfig election = electionConfigRepository.findById(request.getElectionId()) - .orElseThrow(() -> new ElectionNotFoundException(request.getElectionId())); + // Electronic voting disabled — voting is conducted manually. + throw new UnsupportedOperationException("Electronic voting is disabled. Voting is conducted manually."); + } + + @Override + @Transactional + public String saveManualVoteCounts(Long electionId, List counts, String reason) { + ElectionConfig election = electionConfigRepository.findById(electionId) + .orElseThrow(() -> new ElectionNotFoundException(electionId)); if (!election.isVotingOpen()) { throw new ElectionNotActiveException(election.getId(), election.getStatus().name()); } - if (voteRepository.existsByElectionConfigIdAndElectionPostIdAndVoterMemberId( - request.getElectionId(), request.getPostId(), request.getVoterMemberId())) { - throw new VoteAlreadyCastException(request.getVoterMemberId(), request.getPostId()); + // Active share accounts = maximum eligible voters + Integer activeShareAccounts = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_share_account WHERE status_enum = 300", Integer.class); + int maxVotes = activeShareAccounts != null ? activeShareAccounts : 0; + + // Load candidates, capture old votes, accumulate post-wise totals + Map candidateCache = new HashMap<>(); + Map oldVoteCache = new HashMap<>(); + Map postVoteTotals = new HashMap<>(); + Map postNameCache = new HashMap<>(); + + for (ManualVoteCountEntry entry : counts) { + ElectionCandidate candidate = candidateRepository.findById(entry.getCandidateId()) + .orElseThrow(() -> new IllegalArgumentException("Candidate not found: " + entry.getCandidateId())); + candidateCache.put(entry.getCandidateId(), candidate); + oldVoteCache.put(entry.getCandidateId(), candidate.getManualVoteCount() != null ? candidate.getManualVoteCount() : 0); + + Long postId = candidate.getElectionPost().getId(); + postNameCache.put(postId, candidate.getElectionPost().getPostName()); + int votes = entry.getVoteCount() != null ? entry.getVoteCount() : 0; + postVoteTotals.merge(postId, votes, Integer::sum); } - ElectionPost post = electionPostRepository.findById(request.getPostId()) - .orElseThrow(() -> new IllegalArgumentException("Post not found: " + request.getPostId())); + // Validate: no post's total votes can exceed active share accounts + for (Map.Entry e : postVoteTotals.entrySet()) { + if (e.getValue() > maxVotes) { + throw new VoteCountExceedsShareAccountsException( + postNameCache.get(e.getKey()), e.getValue(), maxVotes); + } + } - ElectionCandidate candidate = candidateRepository.findById(request.getCandidateId()) - .orElseThrow(() -> new IllegalArgumentException("Candidate not found: " + request.getCandidateId())); - - ElectionVote vote = ElectionVote.cast(election, post, request.getVoterMemberId(), - candidate, request.getBoothId(), null); - voteRepository.save(vote); + // Persist and build details JSON + boolean isFirstSave = oldVoteCache.values().stream().allMatch(v -> v == 0); + StringBuilder details = new StringBuilder("["); + for (int i = 0; i < counts.size(); i++) { + ManualVoteCountEntry entry = counts.get(i); + ElectionCandidate candidate = candidateCache.get(entry.getCandidateId()); + int oldVotes = oldVoteCache.get(entry.getCandidateId()); + int newVotes = entry.getVoteCount() != null ? entry.getVoteCount() : 0; + candidate.setManualVoteCount(newVotes); + candidateRepository.save(candidate); + if (i > 0) details.append(","); + details.append("{\"candidate\":\"").append(candidate.getCandidateName()) + .append("\",\"post\":\"").append(candidate.getElectionPost().getPostName()) + .append("\",\"votes\":").append(newVotes); + if (!isFirstSave) details.append(",\"previousVotes\":").append(oldVotes); + details.append("}"); + } + details.append("]"); + return details.toString(); } @Override @@ -463,18 +523,43 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe List posts = electionPostRepository.findByElectionConfigIdAndIsActiveTrue(electionId); for (ElectionPost post : posts) { - List voteCounts = voteRepository.countVotesByCandidate(electionId, post.getId()); - int rank = 1; - for (Object[] row : voteCounts) { - Long candidateId = (Long) row[0]; - Long count = (Long) row[1]; - ElectionCandidate candidate = candidateRepository.findById(candidateId).orElse(null); - if (candidate == null) continue; - String result = rank <= post.getTotalSeats() ? "WON" : "LOST"; - ElectionResult r = ElectionResult.create(election, post, candidate, - count.intValue(), result, rank, declaredBy); + List candidates = candidateRepository + .findByElectionConfigIdAndElectionPostIdAndIsActiveTrue(electionId, post.getId()); + + if (candidates.size() == 1) { + // Unopposed — single candidate automatically wins without voting + ElectionCandidate candidate = candidates.get(0); + int votes = candidate.getManualVoteCount() != null ? candidate.getManualVoteCount() : 0; + ElectionResult r = ElectionResult.create(election, post, candidate, votes, "WON", 1, declaredBy); resultRepository.save(r); - rank++; + continue; + } + + boolean hasManualCounts = candidates.stream().anyMatch(c -> c.getManualVoteCount() != null); + + if (hasManualCounts) { + candidates.sort((a, b) -> { + int av = a.getManualVoteCount() != null ? a.getManualVoteCount() : 0; + int bv = b.getManualVoteCount() != null ? b.getManualVoteCount() : 0; + return Integer.compare(bv, av); + }); + int rank = 1; + for (ElectionCandidate candidate : candidates) { + int votes = candidate.getManualVoteCount() != null ? candidate.getManualVoteCount() : 0; + String result = rank <= post.getTotalSeats() ? "WON" : "LOST"; + ElectionResult r = ElectionResult.create(election, post, candidate, votes, result, rank, declaredBy); + resultRepository.save(r); + rank++; + } + } else { + // Fallback: no manual counts entered — rank all candidates with 0 votes + int rank = 1; + for (ElectionCandidate candidate : candidates) { + String result = rank <= post.getTotalSeats() ? "WON" : "LOST"; + ElectionResult r = ElectionResult.create(election, post, candidate, 0, result, rank, declaredBy); + resultRepository.save(r); + rank++; + } } } 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 4946ad71b..b3b4761ec 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 @@ -371,4 +371,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0344_election_candidate_manual_vote_count.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0344_election_candidate_manual_vote_count.xml new file mode 100644 index 000000000..cd355253b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0344_election_candidate_manual_vote_count.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0345_election_candidate_manual_vote_count_drop_default.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0345_election_candidate_manual_vote_count_drop_default.xml new file mode 100644 index 000000000..1ebcc2861 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0345_election_candidate_manual_vote_count_drop_default.xml @@ -0,0 +1,14 @@ + + + + + + + + ALTER TABLE m_election_candidate ALTER COLUMN manual_vote_count DROP DEFAULT; + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0346_election_audit_log_description_to_text.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0346_election_audit_log_description_to_text.xml new file mode 100644 index 000000000..1fe84876a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0346_election_audit_log_description_to_text.xml @@ -0,0 +1,14 @@ + + + + + + + + ALTER TABLE m_election_audit_log ALTER COLUMN description TYPE TEXT; + + +