feat(election): candidate modification lock, director member photo, and comprehensive audit logging

- ElectionConfig: add isCandidateModificationLocked() — blocks remove/update/symbol after voting starts
- ElectionWritePlatformServiceImpl: full audit logging on all 16 operations; failures logged in new transaction (REQUIRES_NEW) so they survive main tx rollback
- DirectorAppointmentData: add memberId field for client photo lookup
- DirectorAppointmentServiceImpl: batch-fetch candidate memberId via ElectionCandidateRepository; pass to response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-03 13:44:49 +05:30
co-authored by Claude Sonnet 4.6
parent 048a4ecfde
commit 00d3a2e333
4 changed files with 146 additions and 2 deletions
@@ -17,6 +17,7 @@ public class DirectorAppointmentData {
private Long id;
private Long electionId;
private Long candidateId;
private Long memberId;
private String candidateName;
private String postName;
private String designation;
@@ -28,4 +29,5 @@ public class DirectorAppointmentData {
private String remarks;
private LocalDateTime createdDate;
private LocalDateTime lastModifiedDate;
private String electionTitle;
}
@@ -88,6 +88,13 @@ public class ElectionConfig extends AbstractAuditableWithUTCDateTimeCustom<Long>
return status == ElectionStatus.VOTING_OPEN;
}
public boolean isCandidateModificationLocked() {
return status == ElectionStatus.VOTING_OPEN
|| status == ElectionStatus.VOTING_CLOSED
|| status == ElectionStatus.RESULT_DECLARED
|| status == ElectionStatus.CANCELLED;
}
public void activate() {
this.status = ElectionStatus.ACTIVE;
}
@@ -10,6 +10,7 @@ import org.apache.fineract.portfolio.election.domain.DirectorAppointment;
import org.apache.fineract.portfolio.election.domain.DirectorAppointmentRepository;
import org.apache.fineract.portfolio.election.domain.DirectorAppointmentStatus;
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
import org.apache.fineract.portfolio.election.domain.ElectionCandidateRepository;
import org.apache.fineract.portfolio.election.domain.ElectionConfigRepository;
import org.apache.fineract.portfolio.election.domain.ElectionResultRepository;
import org.springframework.data.domain.Page;
@@ -24,6 +25,9 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@@ -34,6 +38,7 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic
private final DirectorAppointmentRepository repository;
private final ElectionResultRepository resultRepository;
private final ElectionConfigRepository electionConfigRepository;
private final ElectionCandidateRepository candidateRepository;
private final ElectionAuditService auditService;
@Override
@@ -152,8 +157,25 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic
result = repository.findAll(pageable);
}
Set<Long> electionIds = result.getContent().stream()
.map(DirectorAppointment::getElectionId)
.collect(Collectors.toSet());
Map<Long, String> electionTitleMap = electionConfigRepository.findAllById(electionIds)
.stream()
.collect(Collectors.toMap(e -> e.getId(), e -> e.getTitle() != null ? e.getTitle() : "Election #" + e.getId()));
Set<Long> candidateIds = result.getContent().stream()
.map(DirectorAppointment::getCandidateId)
.collect(Collectors.toSet());
Map<Long, Long> candidateMemberMap = candidateRepository.findAllById(candidateIds)
.stream()
.filter(c -> c.getNomination() != null && c.getNomination().getMemberId() != null)
.collect(Collectors.toMap(c -> c.getId(), c -> c.getNomination().getMemberId()));
return PagedResponse.<DirectorAppointmentData>builder()
.content(result.getContent().stream().map(this::toData).toList())
.content(result.getContent().stream()
.map(d -> toData(d, electionTitleMap.get(d.getElectionId()), candidateMemberMap.get(d.getCandidateId())))
.toList())
.pageNumber(result.getNumber())
.pageSize(result.getSize())
.totalElements(result.getTotalElements())
@@ -207,10 +229,19 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic
}
private DirectorAppointmentData toData(DirectorAppointment d) {
return toData(d, null, null);
}
private DirectorAppointmentData toData(DirectorAppointment d, String electionTitle) {
return toData(d, electionTitle, null);
}
private DirectorAppointmentData toData(DirectorAppointment d, String electionTitle, Long memberId) {
return DirectorAppointmentData.builder()
.id(d.getId())
.electionId(d.getElectionId())
.candidateId(d.getCandidateId())
.memberId(memberId)
.candidateName(d.getCandidateName())
.postName(d.getPostName())
.designation(d.getDesignation())
@@ -222,6 +253,7 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic
.remarks(d.getRemarks())
.createdDate(d.getCreatedDate())
.lastModifiedDate(d.getLastModifiedDate())
.electionTitle(electionTitle)
.build();
}
}
@@ -28,6 +28,7 @@ import org.apache.fineract.portfolio.election.domain.ElectionType;
import org.apache.fineract.portfolio.election.domain.ElectionVote;
import org.apache.fineract.portfolio.election.domain.ElectionVoteRepository;
import org.apache.fineract.portfolio.election.domain.NominationStatus;
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
import org.apache.fineract.portfolio.election.exception.ElectionAlreadyOngoingException;
import org.apache.fineract.portfolio.election.exception.ElectionDateViolationException;
import org.apache.fineract.portfolio.election.exception.ElectionDurationActiveException;
@@ -76,6 +77,7 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
private final ElectionCeoPreCheckService ceoPreCheckService;
private final ElectionNoDueCertificateService noDueCertificateService;
private final JdbcTemplate jdbcTemplate;
private final ElectionAuditService auditService;
private static final Set<ElectionStatus> ONGOING_STATUSES = Set.of(
ElectionStatus.ACTIVE,
@@ -123,6 +125,13 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
}
}
auditService.log(ElectionAuditAction.CREATE_ELECTION, "ELECTION", saved.getId(), saved.getId(),
"Election created: " + saved.getTitle(),
"title=" + saved.getTitle() + ", type=" + saved.getElectionType()
+ ", nominationStart=" + request.getNominationStartDate()
+ ", electionDate=" + request.getElectionDate()
+ ", durationYears=" + request.getDurationYears(), null);
return saved.getId();
}
@@ -140,6 +149,9 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
if (request.getResultDate() != null) election.setResultDate(request.getResultDate());
if (request.getDurationYears() != null) election.setDurationYears(request.getDurationYears());
electionConfigRepository.save(election);
auditService.log(ElectionAuditAction.UPDATE_ELECTION, "ELECTION", electionId, electionId,
"Election updated: " + election.getTitle(),
"title=" + election.getTitle() + ", status=" + election.getStatus(), null);
}
@Override
@@ -150,6 +162,8 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
.orElseThrow(() -> new ElectionNotFoundException(electionId));
election.activate();
electionConfigRepository.save(election);
auditService.log(ElectionAuditAction.ACTIVATE_ELECTION, "ELECTION", electionId, electionId,
"Election activated: " + election.getTitle(), "status=ACTIVE", null);
}
@Override
@@ -159,11 +173,16 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
ElectionConfig election = electionConfigRepository.findById(electionId)
.orElseThrow(() -> new ElectionNotFoundException(electionId));
if (LocalDate.now().isBefore(election.getNominationEndDate())) {
auditService.logInNewTransaction(ElectionAuditAction.CLOSE_NOMINATIONS_FAILED, "ELECTION", electionId, electionId,
"Close nominations failed — nomination end date not reached",
"nominationEndDate=" + election.getNominationEndDate(), null);
throw new ElectionDateViolationException(
"Cannot close nominations before the nomination end date " + election.getNominationEndDate());
}
election.closeNominations();
electionConfigRepository.save(election);
auditService.log(ElectionAuditAction.CLOSE_NOMINATIONS, "ELECTION", electionId, electionId,
"Nominations closed for: " + election.getTitle(), "status=NOMINATION_CLOSED", null);
}
@Override
@@ -173,11 +192,16 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
ElectionConfig election = electionConfigRepository.findById(electionId)
.orElseThrow(() -> new ElectionNotFoundException(electionId));
if (LocalDate.now().isBefore(election.getElectionDate())) {
auditService.logInNewTransaction(ElectionAuditAction.OPEN_VOTING_FAILED, "ELECTION", electionId, electionId,
"Open voting failed — election date not reached",
"electionDate=" + election.getElectionDate(), null);
throw new ElectionDateViolationException(
"Cannot open voting before election date " + election.getElectionDate());
}
election.openVoting();
electionConfigRepository.save(election);
auditService.log(ElectionAuditAction.OPEN_VOTING, "ELECTION", electionId, electionId,
"Voting opened for: " + election.getTitle(), "status=VOTING_OPEN", null);
}
@Override
@@ -206,6 +230,8 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
election.closeVoting();
electionConfigRepository.save(election);
auditService.log(ElectionAuditAction.CLOSE_VOTING, "ELECTION", electionId, electionId,
"Voting closed for: " + election.getTitle(), "status=VOTING_CLOSED", null);
}
@Override
@@ -216,6 +242,8 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
.orElseThrow(() -> new ElectionNotFoundException(electionId));
election.cancel(reason);
electionConfigRepository.save(election);
auditService.log(ElectionAuditAction.CANCEL_ELECTION, "ELECTION", electionId, electionId,
"Election cancelled: " + election.getTitle(), "reason=" + reason, null);
}
@Override
@@ -261,6 +289,11 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
saved.submit(appNumber);
nominationRepository.saveAndFlush(saved);
auditService.log(ElectionAuditAction.SUBMIT_NOMINATION, "NOMINATION", saved.getId(), election.getId(),
"Nomination submitted by " + request.getApplicantName(),
"applicant=" + request.getApplicantName() + ", post=" + post.getPostName()
+ ", appNo=" + appNumber, null);
return saved.getId();
}
@@ -279,6 +312,9 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
}
nomination.withdraw();
nominationRepository.save(nomination);
auditService.log(ElectionAuditAction.WITHDRAW_NOMINATION, "NOMINATION", nominationId,
nomination.getElectionConfig().getId(),
"Nomination withdrawn by " + nomination.getApplicantName(), null, null);
}
@Override
@@ -298,6 +334,9 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
.ifPresent(ceoPrecheckRepository::delete);
nomination.resubmit();
nominationRepository.save(nomination);
auditService.log(ElectionAuditAction.RESUBMIT_NOMINATION, "NOMINATION", nominationId,
nomination.getElectionConfig().getId(),
"Nomination resubmitted by " + nomination.getApplicantName(), null, null);
}
@Override
@@ -311,6 +350,10 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
eligibilityService.performCheck(nomination, nomination.getElectionPost(), checkedBy);
nomination.markUnderReview();
nominationRepository.save(nomination);
auditService.log(ElectionAuditAction.ELIGIBILITY_CHECK, "NOMINATION", nominationId,
nomination.getElectionConfig().getId(),
"Eligibility check run for " + nomination.getApplicantName(),
"checkedBy=" + checkedBy, null);
}
@Override
@@ -410,6 +453,11 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
}
nominationRepository.save(nomination);
String action = "APPROVE".equalsIgnoreCase(request.getAction()) ? "approved" : "rejected";
auditService.log(ElectionAuditAction.APPROVAL_ACTION, "NOMINATION", nominationId,
nomination.getElectionConfig().getId(),
"Approval step " + action + " for " + nomination.getApplicantName(),
"action=" + request.getAction() + ", remarks=" + request.getRemarks(), null);
}
@Override
@@ -446,7 +494,13 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
candidate.setPhotoUrl("/clients/" + nomination.getMemberId() + "/images");
}
return candidateRepository.saveAndFlush(candidate).getId();
Long candidateId = candidateRepository.saveAndFlush(candidate).getId();
auditService.log(ElectionAuditAction.APPROVE_CANDIDATE, "CANDIDATE", candidateId,
nomination.getElectionConfig().getId(),
"Candidate approved: " + nomination.getApplicantName(),
"post=" + nomination.getElectionPost().getPostName()
+ ", serialNo=" + (maxSerial + 1), null);
return candidateId;
}
@Override
@@ -456,6 +510,17 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
ElectionCandidate candidate = candidateRepository.findById(candidateId)
.orElseThrow(() -> new IllegalArgumentException("Candidate not found: " + candidateId));
if (candidate.getElectionConfig().isCandidateModificationLocked()) {
auditService.logInNewTransaction(ElectionAuditAction.REMOVE_CANDIDATE_FAILED, "CANDIDATE", candidateId,
candidate.getElectionConfig().getId(),
"Remove candidate failed — election locked",
"electionStatus=" + candidate.getElectionConfig().getStatus().name(), null);
throw new IllegalStateException(
"Candidate cannot be removed. Election is in "
+ candidate.getElectionConfig().getStatus().name()
+ " status — modifications are locked once voting has started.");
}
String symbol = candidate.getSymbol();
if (symbol != null && !symbol.startsWith("data:") && symbol.contains("|")) {
try { contentStoreService.delete(symbol.split("\\|", 2)[1]); } catch (Exception ignored) {}
@@ -463,6 +528,10 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
candidate.deactivate();
candidateRepository.save(candidate);
auditService.log(ElectionAuditAction.REMOVE_CANDIDATE, "CANDIDATE", candidateId,
candidate.getElectionConfig().getId(),
"Candidate removed: " + candidate.getCandidateName(),
"reason=" + reason, null);
}
@Override
@@ -470,9 +539,25 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
public void updateCandidate(Long candidateId, String symbol, String briefProfile) {
ElectionCandidate candidate = candidateRepository.findById(candidateId)
.orElseThrow(() -> new IllegalArgumentException("Candidate not found: " + candidateId));
if (symbol != null && candidate.getElectionConfig().isCandidateModificationLocked()) {
auditService.logInNewTransaction(ElectionAuditAction.REMOVE_CANDIDATE_FAILED, "CANDIDATE", candidateId,
candidate.getElectionConfig().getId(),
"Symbol update failed — election locked",
"electionStatus=" + candidate.getElectionConfig().getStatus().name(), null);
throw new IllegalStateException(
"Candidate symbol cannot be changed. Election is in "
+ candidate.getElectionConfig().getStatus().name()
+ " status — symbol changes are locked once voting has started.");
}
if (symbol != null) candidate.setSymbol(symbol);
if (briefProfile != null) candidate.setBriefProfile(briefProfile);
candidateRepository.save(candidate);
auditService.log(ElectionAuditAction.UPLOAD_SYMBOL, "CANDIDATE", candidateId,
candidate.getElectionConfig().getId(),
"Candidate updated: " + candidate.getCandidateName(),
"symbolChanged=" + (symbol != null) + ", profileChanged=" + (briefProfile != null), null);
}
@Override
@@ -480,8 +565,23 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
public void uploadCandidateSymbol(Long candidateId, String base64Symbol) {
ElectionCandidate candidate = candidateRepository.findById(candidateId)
.orElseThrow(() -> new IllegalArgumentException("Candidate not found: " + candidateId));
if (candidate.getElectionConfig().isCandidateModificationLocked()) {
auditService.logInNewTransaction(ElectionAuditAction.REMOVE_CANDIDATE_FAILED, "CANDIDATE", candidateId,
candidate.getElectionConfig().getId(),
"Symbol upload failed — election locked",
"electionStatus=" + candidate.getElectionConfig().getStatus().name(), null);
throw new IllegalStateException(
"Candidate symbol cannot be uploaded. Election is in "
+ candidate.getElectionConfig().getStatus().name()
+ " status — symbol changes are locked once voting has started.");
}
candidate.setSymbol(base64Symbol);
candidateRepository.save(candidate);
auditService.log(ElectionAuditAction.UPLOAD_SYMBOL, "CANDIDATE", candidateId,
candidate.getElectionConfig().getId(),
"Symbol uploaded for: " + candidate.getCandidateName(), null, null);
}
@Override
@@ -611,6 +711,9 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
election.declareResult();
electionConfigRepository.save(election);
auditService.log(ElectionAuditAction.DECLARE_RESULTS, "ELECTION", electionId, electionId,
"Results declared for: " + election.getTitle(),
"declaredBy=" + declaredBy + ", posts=" + posts.size(), null);
}
private void initApprovalSteps(ElectionNomination nomination) {