feat(election): election duration blocking, director tenure auto-fill, audit logging, and scheduler

- Add durationYears to ElectionConfig with Liquibase migration (0348)
- Block new election creation when previous election duration is still active (PostgreSQL-compatible native query)
- Auto-override director tenureEndDate from election durationEndDate on appoint and update
- Add ElectionSchedulerConfiguration to enable @Scheduled support
- Fix JPQL EclipseLink enum compatibility in expireByTenureDate query
- Add full audit logging to DirectorAppointmentServiceImpl (appoint, update, remove, auto-expire)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-03 12:25:43 +05:30
co-authored by Claude Sonnet 4.6
parent 7ce7353e7a
commit 048a4ecfde
12 changed files with 122 additions and 10 deletions
@@ -0,0 +1,9 @@
package org.apache.fineract.portfolio.election.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
@Configuration
@EnableScheduling
public class ElectionSchedulerConfiguration {
}
@@ -29,4 +29,6 @@ public class ElectionConfigData {
private Long totalNominations;
private Long approvedNominations;
private Long pendingNominations;
private Integer durationYears;
private LocalDate durationEndDate;
}
@@ -22,5 +22,6 @@ public class ElectionConfigRequest {
private LocalDate electionDate;
private LocalDate resultDate;
private String remarks;
private Integer durationYears;
private List<ElectionPostRequest> posts;
}
@@ -5,6 +5,7 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
@@ -26,6 +27,6 @@ public interface DirectorAppointmentRepository extends JpaRepository<DirectorApp
boolean existsByElectionIdAndCandidateId(Long electionId, Long candidateId);
@Modifying
@Query("UPDATE DirectorAppointment d SET d.status = 'TENURE_ENDED' WHERE d.status = 'ACTIVE' AND d.tenureEndDate < :today")
int expireByTenureDate(LocalDate today);
@Query("UPDATE DirectorAppointment d SET d.status = org.apache.fineract.portfolio.election.domain.DirectorAppointmentStatus.TENURE_ENDED WHERE d.status = org.apache.fineract.portfolio.election.domain.DirectorAppointmentStatus.ACTIVE AND d.tenureEndDate < :today")
int expireByTenureDate(@Param("today") LocalDate today);
}
@@ -49,12 +49,15 @@ public class ElectionConfig extends AbstractAuditableWithUTCDateTimeCustom<Long>
@Column(name = "remarks", columnDefinition = "TEXT")
private String remarks;
@Column(name = "duration_years")
private Integer durationYears;
@OneToMany(mappedBy = "electionConfig", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<ElectionPost> posts = new ArrayList<>();
private ElectionConfig(String title, String description, ElectionType electionType,
LocalDate nominationStartDate, LocalDate nominationEndDate,
LocalDate electionDate, LocalDate resultDate) {
LocalDate electionDate, LocalDate resultDate, Integer durationYears) {
this.title = title;
this.description = description;
this.electionType = electionType;
@@ -62,15 +65,16 @@ public class ElectionConfig extends AbstractAuditableWithUTCDateTimeCustom<Long>
this.nominationEndDate = nominationEndDate;
this.electionDate = electionDate;
this.resultDate = resultDate;
this.durationYears = durationYears;
this.status = ElectionStatus.DRAFT;
this.isActive = true;
}
public static ElectionConfig create(String title, String description, ElectionType electionType,
LocalDate nominationStartDate, LocalDate nominationEndDate,
LocalDate electionDate, LocalDate resultDate) {
LocalDate electionDate, LocalDate resultDate, Integer durationYears) {
return new ElectionConfig(title, description, electionType, nominationStartDate,
nominationEndDate, electionDate, resultDate);
nominationEndDate, electionDate, resultDate, durationYears);
}
public boolean isNominationOpen() {
@@ -10,6 +10,7 @@ import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.time.LocalDate;
@Repository
public interface ElectionConfigRepository extends JpaRepository<ElectionConfig, Long> {
@@ -34,4 +35,7 @@ public interface ElectionConfigRepository extends JpaRepository<ElectionConfig,
Optional<ElectionConfig> findByIdAndIsActiveTrue(Long id);
boolean existsByStatusIn(Set<ElectionStatus> statuses);
@Query(value = "SELECT * FROM m_election_config WHERE status = 'RESULT_DECLARED' AND duration_years IS NOT NULL AND election_date + (duration_years || ' years')::interval > :today LIMIT 1", nativeQuery = true)
Optional<ElectionConfig> findFirstWithActiveDuration(@Param("today") LocalDate today);
}
@@ -0,0 +1,14 @@
package org.apache.fineract.portfolio.election.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
import java.time.LocalDate;
public class ElectionDurationActiveException extends AbstractPlatformDomainRuleException {
public ElectionDurationActiveException(LocalDate expiryDate) {
super("error.msg.election.duration.active",
"The previous election's tenure period is still active until " + expiryDate
+ ". A new election cannot be created until this duration expires.");
}
}
@@ -9,6 +9,8 @@ import org.apache.fineract.portfolio.election.data.PendingDirectorData;
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.ElectionConfigRepository;
import org.apache.fineract.portfolio.election.domain.ElectionResultRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -31,12 +33,19 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic
private final DirectorAppointmentRepository repository;
private final ElectionResultRepository resultRepository;
private final ElectionConfigRepository electionConfigRepository;
private final ElectionAuditService auditService;
@Override
@Transactional
public Long appoint(DirectorAppointmentRequest req) {
overrideTenureEndFromElection(req);
validateTenureDates(req);
if (repository.existsByElectionIdAndCandidateId(req.getElectionId(), req.getCandidateId())) {
auditService.log(ElectionAuditAction.APPOINT_DIRECTOR_FAILED, "DIRECTOR_APPOINTMENT", null,
req.getElectionId(),
"Director appointment failed — candidate already appointed",
"candidateId=" + req.getCandidateId() + ", electionId=" + req.getElectionId(), null);
throw new IllegalStateException(
"Candidate " + req.getCandidateId() + " is already appointed for election " + req.getElectionId());
}
@@ -44,7 +53,15 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic
req.getElectionId(), req.getCandidateId(),
req.getCandidateName(), req.getPostName());
applyFields(d, req);
return repository.saveAndFlush(d).getId();
Long id = repository.saveAndFlush(d).getId();
auditService.log(ElectionAuditAction.APPOINT_DIRECTOR, "DIRECTOR_APPOINTMENT", id,
req.getElectionId(),
req.getCandidateName() + " appointed as " + req.getDesignation(),
"designation=" + req.getDesignation()
+ ", tenureStart=" + req.getTenureStartDate()
+ ", tenureEnd=" + req.getTenureEndDate()
+ ", resolutionNo=" + req.getResolutionNo(), null);
return id;
}
@Override
@@ -67,10 +84,26 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic
@Override
@Transactional
public DirectorAppointmentData update(Long id, DirectorAppointmentRequest req) {
DirectorAppointment existing = findOrThrow(id);
if (req.getElectionId() == null) req.setElectionId(existing.getElectionId());
overrideTenureEndFromElection(req);
validateTenureDates(req);
DirectorAppointment d = findOrThrow(id);
applyFields(d, req);
return toData(repository.save(d));
boolean isRemoval = "REMOVED".equalsIgnoreCase(req.getStatus())
&& existing.getStatus() != DirectorAppointmentStatus.REMOVED;
applyFields(existing, req);
DirectorAppointmentData result = toData(repository.save(existing));
ElectionAuditAction action = isRemoval
? ElectionAuditAction.REMOVE_DIRECTOR_APPOINTMENT
: ElectionAuditAction.UPDATE_DIRECTOR_APPOINTMENT;
String description = isRemoval
? existing.getCandidateName() + " marked as Removed"
: existing.getCandidateName() + " appointment updated";
auditService.log(action, "DIRECTOR_APPOINTMENT", id, existing.getElectionId(),
description,
"status=" + req.getStatus()
+ ", designation=" + req.getDesignation()
+ ", remarks=" + req.getRemarks(), null);
return result;
}
@Override
@@ -92,6 +125,9 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic
int updated = repository.expireByTenureDate(LocalDate.now());
if (updated > 0) {
log.info("Auto-expired {} director appointment(s) whose tenure ended.", updated);
auditService.log(ElectionAuditAction.UPDATE_DIRECTOR_APPOINTMENT, "DIRECTOR_APPOINTMENT", null, null,
"Scheduler auto-expired " + updated + " director appointment(s)",
"expiredCount=" + updated + ", date=" + LocalDate.now(), null);
}
}
@@ -143,6 +179,16 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic
if (req.getPostName() != null) d.setPostName(req.getPostName());
}
private void overrideTenureEndFromElection(DirectorAppointmentRequest req) {
if (req.getElectionId() == null) return;
electionConfigRepository.findById(req.getElectionId()).ifPresent(election -> {
if (election.getDurationYears() != null && election.getElectionDate() != null) {
LocalDate durationEndDate = election.getElectionDate().plusYears(election.getDurationYears());
req.setTenureEndDate(durationEndDate);
}
});
}
private void validateTenureDates(DirectorAppointmentRequest req) {
if (req.getTenureStartDate() != null && req.getTenureEndDate() != null
&& !req.getTenureEndDate().isAfter(req.getTenureStartDate())) {
@@ -291,6 +291,10 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ
}
private ElectionConfigData toConfigData(ElectionConfig election) {
LocalDate durationEndDate = null;
if (election.getElectionDate() != null && election.getDurationYears() != null) {
durationEndDate = election.getElectionDate().plusYears(election.getDurationYears());
}
return ElectionConfigData.builder()
.id(election.getId())
.title(election.getTitle())
@@ -303,6 +307,8 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ
.status(election.getStatus() != null ? election.getStatus().name() : null)
.isActive(election.getIsActive())
.remarks(election.getRemarks())
.durationYears(election.getDurationYears())
.durationEndDate(durationEndDate)
.build();
}
@@ -30,6 +30,7 @@ import org.apache.fineract.portfolio.election.domain.ElectionVoteRepository;
import org.apache.fineract.portfolio.election.domain.NominationStatus;
import org.apache.fineract.portfolio.election.exception.ElectionAlreadyOngoingException;
import org.apache.fineract.portfolio.election.exception.ElectionDateViolationException;
import org.apache.fineract.portfolio.election.exception.ElectionDurationActiveException;
import org.apache.fineract.portfolio.election.exception.ElectionNotFoundException;
import org.apache.fineract.portfolio.election.exception.ElectionNominationNotFoundException;
import org.apache.fineract.portfolio.election.exception.ElectionNotActiveException;
@@ -92,6 +93,11 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
throw new ElectionAlreadyOngoingException();
}
electionConfigRepository.findFirstWithActiveDuration(LocalDate.now()).ifPresent(prev -> {
LocalDate expiryDate = prev.getElectionDate().plusYears(prev.getDurationYears());
throw new ElectionDurationActiveException(expiryDate);
});
validateDateOrder(request.getNominationStartDate(), request.getNominationEndDate(),
request.getElectionDate(), request.getResultDate());
@@ -102,7 +108,8 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
request.getNominationStartDate(),
request.getNominationEndDate(),
request.getElectionDate(),
request.getResultDate()
request.getResultDate(),
request.getDurationYears()
);
ElectionConfig saved = electionConfigRepository.saveAndFlush(election);
@@ -131,6 +138,7 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe
if (request.getNominationEndDate() != null) election.setNominationEndDate(request.getNominationEndDate());
if (request.getElectionDate() != null) election.setElectionDate(request.getElectionDate());
if (request.getResultDate() != null) election.setResultDate(request.getResultDate());
if (request.getDurationYears() != null) election.setDurationYears(request.getDurationYears());
electionConfigRepository.save(election);
}
@@ -375,4 +375,5 @@
<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"/>
<include file="parts/0347_election_director_appointment.xml" relativeToChangelogFile="true"/>
<include file="parts/0348_election_config_duration_years.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,16 @@
<?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.6.xsd">
<changeSet id="0348-add-duration-years-to-election-config" author="lincpay">
<addColumn tableName="m_election_config">
<column name="duration_years" type="INT">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>