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:
co-authored by
Claude Sonnet 4.6
parent
048a4ecfde
commit
00d3a2e333
+2
@@ -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;
|
||||
}
|
||||
|
||||
+7
@@ -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;
|
||||
}
|
||||
|
||||
+33
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+104
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user