feat(election): Board of Directors — director appointment management

- DirectorAppointment entity + Liquibase migration (0347)
- CRUD API: POST/GET/PUT/DELETE /v1/election/directors with @Valid Bean Validation
- GET /v1/election/directors/pending — WON candidates not yet appointed
- Duplicate prevention: existsByElectionIdAndCandidateId check on appoint
- Business validation: tenure end must be after start; tenure start cannot be before appointment date
- @Scheduled daily midnight job to auto-expire tenure-ended directors via bulk JPQL UPDATE
- Full audit logging for all director actions (success + failure) via ElectionAuditService
- PendingDirectorData DTO; DirectorAppointmentStatus enum (ACTIVE/RESIGNED/REMOVED/TENURE_ENDED)
- ElectionAuditAction: added APPOINT_DIRECTOR, UPDATE_DIRECTOR_APPOINTMENT, REMOVE_DIRECTOR_APPOINTMENT + FAILED variants
- ElectionResultRepository: findByResult(String) for pending director lookup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-02 12:50:04 +05:30
co-authored by Claude Sonnet 4.6
parent ba199d78e7
commit 7ce7353e7a
13 changed files with 597 additions and 1 deletions
@@ -0,0 +1,121 @@
package org.apache.fineract.portfolio.election.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import jakarta.ws.rs.*;
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.DirectorAppointmentRequest;
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
import org.apache.fineract.portfolio.election.service.DirectorAppointmentService;
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
@Path("/v1/election/directors")
@Tag(name = "Board of Directors", description = "Manage elected director appointments")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
public class DirectorAppointmentApiResource {
private final DirectorAppointmentService service;
private final ElectionAuditService auditService;
@GET
@Path("/pending")
@Operation(summary = "Won candidates not yet appointed as directors")
public Response pending() {
return Response.ok(service.retrievePending()).build();
}
@GET
@Operation(summary = "List all director appointments (paginated)")
public Response list(
@QueryParam("status") String status,
@QueryParam("electionId") Long electionId,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("20") int size) {
return Response.ok(service.retrieveAll(status, electionId, page, size)).build();
}
@GET
@Path("/{id}")
@Operation(summary = "Get director appointment detail")
public Response get(@PathParam("id") Long id) {
return Response.ok(service.retrieve(id)).build();
}
@POST
@Operation(summary = "Appoint a director")
public Response appoint(@Valid DirectorAppointmentRequest request,
@Context HttpServletRequest httpRequest) {
Long id;
try {
id = service.appoint(request);
} catch (RuntimeException e) {
auditService.logInNewTransaction(ElectionAuditAction.APPOINT_DIRECTOR_FAILED,
"DIRECTOR", null, request.getElectionId(),
"Director appointment failed: " + e.getMessage(), null, clientIp(httpRequest));
throw e;
}
auditService.log(ElectionAuditAction.APPOINT_DIRECTOR,
"DIRECTOR", id, request.getElectionId(),
"Director appointed: " + request.getCandidateName() + " as " + request.getDesignation(),
"candidateId=" + request.getCandidateId() + ", post=" + request.getPostName(),
clientIp(httpRequest));
return Response.ok(Map.of("resourceId", id)).build();
}
@PUT
@Path("/{id}")
@Operation(summary = "Update director appointment details")
public Response update(@PathParam("id") Long id, @Valid DirectorAppointmentRequest request,
@Context HttpServletRequest httpRequest) {
try {
service.update(id, request);
} catch (RuntimeException e) {
auditService.logInNewTransaction(ElectionAuditAction.UPDATE_DIRECTOR_APPOINTMENT_FAILED,
"DIRECTOR", id, request.getElectionId(),
"Director update failed: " + e.getMessage(), null, clientIp(httpRequest));
throw e;
}
auditService.log(ElectionAuditAction.UPDATE_DIRECTOR_APPOINTMENT,
"DIRECTOR", id, request.getElectionId(),
"Director appointment updated: " + request.getCandidateName(),
"designation=" + request.getDesignation() + ", status=" + request.getStatus(),
clientIp(httpRequest));
return Response.ok(Map.of("resourceId", id)).build();
}
@DELETE
@Path("/{id}")
@Operation(summary = "Remove a director appointment")
public Response delete(@PathParam("id") Long id,
@Context HttpServletRequest httpRequest) {
try {
service.delete(id);
} catch (RuntimeException e) {
auditService.logInNewTransaction(ElectionAuditAction.REMOVE_DIRECTOR_APPOINTMENT_FAILED,
"DIRECTOR", id, null,
"Director removal failed: " + e.getMessage(), null, clientIp(httpRequest));
throw e;
}
auditService.log(ElectionAuditAction.REMOVE_DIRECTOR_APPOINTMENT,
"DIRECTOR", id, null,
"Director appointment removed (id=" + id + ")", null,
clientIp(httpRequest));
return Response.ok(Map.of("resourceId", id)).build();
}
private String clientIp(HttpServletRequest req) {
String ip = req.getHeader("X-Forwarded-For");
return (ip != null && !ip.isBlank()) ? ip.split(",")[0].trim() : req.getRemoteAddr();
}
}
@@ -0,0 +1,31 @@
package org.apache.fineract.portfolio.election.data;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class DirectorAppointmentData {
private Long id;
private Long electionId;
private Long candidateId;
private String candidateName;
private String postName;
private String designation;
private LocalDate tenureStartDate;
private LocalDate tenureEndDate;
private String resolutionNo;
private LocalDate appointmentDate;
private String status;
private String remarks;
private LocalDateTime createdDate;
private LocalDateTime lastModifiedDate;
}
@@ -0,0 +1,41 @@
package org.apache.fineract.portfolio.election.data;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
@Data
@NoArgsConstructor
public class DirectorAppointmentRequest {
@NotNull(message = "Election ID is required")
private Long electionId;
@NotNull(message = "Candidate ID is required")
private Long candidateId;
@NotBlank(message = "Candidate name is required")
private String candidateName;
@NotBlank(message = "Post name is required")
private String postName;
@NotBlank(message = "Designation is required")
private String designation;
@NotNull(message = "Appointment date is required")
private LocalDate appointmentDate;
@NotNull(message = "Tenure start date is required")
private LocalDate tenureStartDate;
@NotNull(message = "Tenure end date is required")
private LocalDate tenureEndDate;
private String resolutionNo;
private String status;
private String remarks;
}
@@ -0,0 +1,19 @@
package org.apache.fineract.portfolio.election.data;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class PendingDirectorData {
private Long electionId;
private String electionTitle;
private Long candidateId;
private String candidateName;
private String postName;
private Integer voteCount;
}
@@ -0,0 +1,79 @@
package org.apache.fineract.portfolio.election.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Entity
@Table(name = "m_election_director_appointment")
@Getter
@Setter
@NoArgsConstructor
public class DirectorAppointment extends AbstractPersistableCustom<Long> {
@Column(name = "election_id", nullable = false)
private Long electionId;
@Column(name = "candidate_id", nullable = false)
private Long candidateId;
@Column(name = "candidate_name", nullable = false, length = 200)
private String candidateName;
@Column(name = "post_name", nullable = false, length = 200)
private String postName;
@Column(name = "designation", length = 200)
private String designation;
@Column(name = "tenure_start_date")
private LocalDate tenureStartDate;
@Column(name = "tenure_end_date")
private LocalDate tenureEndDate;
@Column(name = "resolution_no", length = 100)
private String resolutionNo;
@Column(name = "appointment_date")
private LocalDate appointmentDate;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 30)
private DirectorAppointmentStatus status = DirectorAppointmentStatus.ACTIVE;
@Column(name = "remarks", columnDefinition = "TEXT")
private String remarks;
@Column(name = "created_date", nullable = false, updatable = false)
private LocalDateTime createdDate;
@Column(name = "last_modified_date")
private LocalDateTime lastModifiedDate;
@PrePersist
void onPersist() {
this.createdDate = LocalDateTime.now();
}
@PreUpdate
void onUpdate() {
this.lastModifiedDate = LocalDateTime.now();
}
public static DirectorAppointment create(Long electionId, Long candidateId,
String candidateName, String postName) {
DirectorAppointment d = new DirectorAppointment();
d.electionId = electionId;
d.candidateId = candidateId;
d.candidateName = candidateName;
d.postName = postName;
d.status = DirectorAppointmentStatus.ACTIVE;
return d;
}
}
@@ -0,0 +1,31 @@
package org.apache.fineract.portfolio.election.domain;
import org.springframework.data.domain.Page;
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.stereotype.Repository;
import java.time.LocalDate;
import java.util.List;
@Repository
public interface DirectorAppointmentRepository extends JpaRepository<DirectorAppointment, Long> {
Page<DirectorAppointment> findAll(Pageable pageable);
Page<DirectorAppointment> findByStatus(DirectorAppointmentStatus status, Pageable pageable);
Page<DirectorAppointment> findByElectionId(Long electionId, Pageable pageable);
Page<DirectorAppointment> findByElectionIdAndStatus(Long electionId, DirectorAppointmentStatus status, Pageable pageable);
List<DirectorAppointment> findByElectionIdAndCandidateId(Long electionId, Long candidateId);
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);
}
@@ -0,0 +1,8 @@
package org.apache.fineract.portfolio.election.domain;
public enum DirectorAppointmentStatus {
ACTIVE,
RESIGNED,
REMOVED,
TENURE_ENDED
}
@@ -22,5 +22,8 @@ public enum ElectionAuditAction {
// Voting
CAST_VOTE, MANUAL_VOTE_ENTRY, MANUAL_VOTE_ENTRY_FAILED,
// Results
DECLARE_RESULTS, DECLARE_RESULTS_FAILED
DECLARE_RESULTS, DECLARE_RESULTS_FAILED,
// Board of Directors
APPOINT_DIRECTOR, UPDATE_DIRECTOR_APPOINTMENT, REMOVE_DIRECTOR_APPOINTMENT,
APPOINT_DIRECTOR_FAILED, UPDATE_DIRECTOR_APPOINTMENT_FAILED, REMOVE_DIRECTOR_APPOINTMENT_FAILED
}
@@ -13,4 +13,6 @@ public interface ElectionResultRepository extends JpaRepository<ElectionResult,
List<ElectionResult> findByElectionConfigIdAndElectionPostId(Long electionId, Long postId);
boolean existsByElectionConfigId(Long electionId);
List<ElectionResult> findByResult(String result);
}
@@ -0,0 +1,25 @@
package org.apache.fineract.portfolio.election.service;
import org.apache.fineract.portfolio.election.data.DirectorAppointmentData;
import org.apache.fineract.portfolio.election.data.DirectorAppointmentRequest;
import org.apache.fineract.portfolio.election.data.PagedResponse;
import org.apache.fineract.portfolio.election.data.PendingDirectorData;
import java.util.List;
public interface DirectorAppointmentService {
Long appoint(DirectorAppointmentRequest request);
List<PendingDirectorData> retrievePending();
void autoExpireByTenure();
DirectorAppointmentData update(Long id, DirectorAppointmentRequest request);
void delete(Long id);
DirectorAppointmentData retrieve(Long id);
PagedResponse<DirectorAppointmentData> retrieveAll(String status, Long electionId, int page, int size);
}
@@ -0,0 +1,181 @@
package org.apache.fineract.portfolio.election.service;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.apache.fineract.portfolio.election.data.DirectorAppointmentData;
import org.apache.fineract.portfolio.election.data.DirectorAppointmentRequest;
import org.apache.fineract.portfolio.election.data.PagedResponse;
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.ElectionResultRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.List;
@Service
@RequiredArgsConstructor
public class DirectorAppointmentServiceImpl implements DirectorAppointmentService {
private static final Logger log = LoggerFactory.getLogger(DirectorAppointmentServiceImpl.class);
private final DirectorAppointmentRepository repository;
private final ElectionResultRepository resultRepository;
@Override
@Transactional
public Long appoint(DirectorAppointmentRequest req) {
validateTenureDates(req);
if (repository.existsByElectionIdAndCandidateId(req.getElectionId(), req.getCandidateId())) {
throw new IllegalStateException(
"Candidate " + req.getCandidateId() + " is already appointed for election " + req.getElectionId());
}
DirectorAppointment d = DirectorAppointment.create(
req.getElectionId(), req.getCandidateId(),
req.getCandidateName(), req.getPostName());
applyFields(d, req);
return repository.saveAndFlush(d).getId();
}
@Override
@Transactional(readOnly = true)
public List<PendingDirectorData> retrievePending() {
return resultRepository.findByResult("WON").stream()
.filter(r -> !repository.existsByElectionIdAndCandidateId(
r.getElectionConfig().getId(), r.getCandidate().getId()))
.map(r -> PendingDirectorData.builder()
.electionId(r.getElectionConfig().getId())
.electionTitle(r.getElectionConfig().getTitle())
.candidateId(r.getCandidate().getId())
.candidateName(r.getCandidate().getCandidateName())
.postName(r.getElectionPost().getPostName())
.voteCount(r.getVoteCount())
.build())
.toList();
}
@Override
@Transactional
public DirectorAppointmentData update(Long id, DirectorAppointmentRequest req) {
validateTenureDates(req);
DirectorAppointment d = findOrThrow(id);
applyFields(d, req);
return toData(repository.save(d));
}
@Override
@Transactional
public void delete(Long id) {
repository.delete(findOrThrow(id));
}
@Override
@Transactional(readOnly = true)
public DirectorAppointmentData retrieve(Long id) {
return toData(findOrThrow(id));
}
@Override
@Transactional
@Scheduled(cron = "0 0 0 * * *")
public void autoExpireByTenure() {
int updated = repository.expireByTenureDate(LocalDate.now());
if (updated > 0) {
log.info("Auto-expired {} director appointment(s) whose tenure ended.", updated);
}
}
@Override
@Transactional(readOnly = true)
public PagedResponse<DirectorAppointmentData> retrieveAll(String status, Long electionId, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdDate").descending());
Page<DirectorAppointment> result;
DirectorAppointmentStatus statusEnum = null;
if (status != null && !status.isBlank()) {
try { statusEnum = DirectorAppointmentStatus.valueOf(status.toUpperCase()); } catch (Exception ignored) {}
}
if (electionId != null && statusEnum != null) {
result = repository.findByElectionIdAndStatus(electionId, statusEnum, pageable);
} else if (electionId != null) {
result = repository.findByElectionId(electionId, pageable);
} else if (statusEnum != null) {
result = repository.findByStatus(statusEnum, pageable);
} else {
result = repository.findAll(pageable);
}
return PagedResponse.<DirectorAppointmentData>builder()
.content(result.getContent().stream().map(this::toData).toList())
.pageNumber(result.getNumber())
.pageSize(result.getSize())
.totalElements(result.getTotalElements())
.totalPages(result.getTotalPages())
.first(result.isFirst())
.last(result.isLast())
.build();
}
// -------------------------------------------------------------------------
private void applyFields(DirectorAppointment d, DirectorAppointmentRequest req) {
if (req.getDesignation() != null) d.setDesignation(req.getDesignation());
if (req.getTenureStartDate() != null) d.setTenureStartDate(req.getTenureStartDate());
if (req.getTenureEndDate() != null) d.setTenureEndDate(req.getTenureEndDate());
if (req.getResolutionNo() != null) d.setResolutionNo(req.getResolutionNo());
if (req.getAppointmentDate() != null) d.setAppointmentDate(req.getAppointmentDate());
if (req.getRemarks() != null) d.setRemarks(req.getRemarks());
if (req.getStatus() != null) {
try { d.setStatus(DirectorAppointmentStatus.valueOf(req.getStatus().toUpperCase())); } catch (Exception ignored) {}
}
if (req.getCandidateName() != null) d.setCandidateName(req.getCandidateName());
if (req.getPostName() != null) d.setPostName(req.getPostName());
}
private void validateTenureDates(DirectorAppointmentRequest req) {
if (req.getTenureStartDate() != null && req.getTenureEndDate() != null
&& !req.getTenureEndDate().isAfter(req.getTenureStartDate())) {
throw new IllegalArgumentException("Tenure end date must be after tenure start date");
}
if (req.getAppointmentDate() != null && req.getTenureStartDate() != null
&& req.getTenureStartDate().isBefore(req.getAppointmentDate())) {
throw new IllegalArgumentException("Tenure start date cannot be before appointment date");
}
}
private DirectorAppointment findOrThrow(Long id) {
return repository.findById(id).orElseThrow(
() -> new AbstractPlatformResourceNotFoundException("error.msg.director.appointment.not.found",
"Director appointment not found with id: " + id, id) {});
}
private DirectorAppointmentData toData(DirectorAppointment d) {
return DirectorAppointmentData.builder()
.id(d.getId())
.electionId(d.getElectionId())
.candidateId(d.getCandidateId())
.candidateName(d.getCandidateName())
.postName(d.getPostName())
.designation(d.getDesignation())
.tenureStartDate(d.getTenureStartDate())
.tenureEndDate(d.getTenureEndDate())
.resolutionNo(d.getResolutionNo())
.appointmentDate(d.getAppointmentDate())
.status(d.getStatus() != null ? d.getStatus().name() : null)
.remarks(d.getRemarks())
.createdDate(d.getCreatedDate())
.lastModifiedDate(d.getLastModifiedDate())
.build();
}
}
@@ -374,4 +374,5 @@
<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"/>
<include file="parts/0347_election_director_appointment.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,54 @@
<?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="0347-election-director-appointment" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_election_director_appointment"/></not>
</preConditions>
<createTable tableName="m_election_director_appointment">
<column name="id" type="BIGSERIAL" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="election_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="candidate_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="candidate_name" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="post_name" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="designation" type="VARCHAR(200)"/>
<column name="tenure_start_date" type="DATE"/>
<column name="tenure_end_date" type="DATE"/>
<column name="resolution_no" type="VARCHAR(100)"/>
<column name="appointment_date" type="DATE"/>
<column name="status" type="VARCHAR(30)" defaultValue="ACTIVE">
<constraints nullable="false"/>
</column>
<column name="remarks" type="TEXT"/>
<column name="created_date" type="TIMESTAMP" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
<column name="last_modified_date" type="TIMESTAMP"/>
</createTable>
<createIndex indexName="idx_director_appt_election"
tableName="m_election_director_appointment">
<column name="election_id"/>
</createIndex>
<createIndex indexName="idx_director_appt_status"
tableName="m_election_director_appointment">
<column name="status"/>
</createIndex>
</changeSet>
</databaseChangeLog>