feat(election): comprehensive audit log — rich details for all actions

- 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 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-01 14:59:23 +05:30
co-authored by Claude Sonnet 4.6
parent 99a89d5d81
commit 99cc972804
22 changed files with 586 additions and 106 deletions
@@ -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<String, String> 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") + "\"";
}
}
@@ -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") + "\"";
}
}
@@ -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") + "\"";
}
}
@@ -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<ManualVoteCountEntry> 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<ElectionResultData> 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<Object[]> rows = voteRepository.voteSummaryByCandidate(electionId);
List<Map<String, Object>> breakdown = rows.stream().map(r -> {
Map<String, Object> 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<String, Object> 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") + "\"";
}
}
@@ -31,4 +31,5 @@ public class ElectionCandidateData {
private String location;
private Integer shareCount;
private Integer voteCount;
private Integer manualVoteCount;
}
@@ -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;
}
@@ -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<ManualVoteCountEntry> counts;
private String reason;
}
@@ -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
}
@@ -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)
@@ -51,6 +51,9 @@ public class ElectionCandidate extends AbstractPersistableCustom<Long> {
@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,
@@ -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.");
}
}
@@ -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.");
}
}
@@ -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.
*
@@ -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<String, Object> getAuditLogPaged(Long electionId, String entityType,
@@ -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
@@ -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())
@@ -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<ManualVoteCountEntry> counts, String reason);
void declareResults(Long electionId, Long declaredBy);
}
@@ -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<ElectionPost> posts = electionPostRepository.findByElectionConfigIdAndIsActiveTrue(electionId);
for (ElectionPost post : posts) {
List<ElectionCandidate> 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<ManualVoteCountEntry> 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<Long, ElectionCandidate> candidateCache = new HashMap<>();
Map<Long, Integer> oldVoteCache = new HashMap<>();
Map<Long, Integer> postVoteTotals = new HashMap<>();
Map<Long, String> 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<Long, Integer> 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<ElectionPost> posts = electionPostRepository.findByElectionConfigIdAndIsActiveTrue(electionId);
for (ElectionPost post : posts) {
List<Object[]> 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<ElectionCandidate> 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++;
}
}
}
@@ -371,4 +371,7 @@
<include file="parts/0341_election_audit_log.xml" relativeToChangelogFile="true"/>
<include file="parts/0342_election_post_add_eligibility_columns.xml" relativeToChangelogFile="true"/>
<include file="parts/0343_client_sub_caste_column.xml" relativeToChangelogFile="true"/>
<include file="parts/0344_election_candidate_manual_vote_count.xml" relativeToChangelogFile="true"/>
<include file="parts/0345_election_candidate_manual_vote_count_drop_default.xml" relativeToChangelogFile="true"/>
<include file="parts/0346_election_audit_log_description_to_text.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,18 @@
<?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="0344_election_candidate_add_manual_vote_count" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_election_candidate" columnName="manual_vote_count"/></not>
</preConditions>
<addColumn tableName="m_election_candidate">
<column name="manual_vote_count" type="INT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,14 @@
<?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="0345_election_candidate_manual_vote_count_drop_default" author="CBS">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_election_candidate" columnName="manual_vote_count"/>
</preConditions>
<sql>ALTER TABLE m_election_candidate ALTER COLUMN manual_vote_count DROP DEFAULT;</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,14 @@
<?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="0346_election_audit_log_description_to_text" author="CBS">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_election_audit_log" columnName="description"/>
</preConditions>
<sql>ALTER TABLE m_election_audit_log ALTER COLUMN description TYPE TEXT;</sql>
</changeSet>
</databaseChangeLog>