feat(election): committee management — CRUD, members, image, and director fix

- Add Committee entity, CommitteeMember, repositories, service, and REST API
- Liquibase migrations: 0349 committee tables, 0350 committee image column
- Committee supports profile image (base64 TEXT), name, description, status
- Current directors: fix EclipseLink native query (?1 positional param) and add
  fallback to most recent RESULT_DECLARED election when no active duration found
- DirectorAppointmentRepository: add findByElectionIdAndStatus (non-pageable)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-03 16:39:42 +05:30
co-authored by Claude Sonnet 4.6
parent 00d3a2e333
commit 2606427203
16 changed files with 539 additions and 2 deletions
@@ -0,0 +1,71 @@
package org.apache.fineract.portfolio.election.api;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.election.data.CommitteeRequest;
import org.apache.fineract.portfolio.election.service.CommitteeService;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
@Path("/v1/election/committees")
@Component
@RequiredArgsConstructor
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CommitteeApiResource {
private final CommitteeService committeeService;
@GET
public Response getAll() {
return Response.ok(committeeService.retrieveAll()).build();
}
@GET
@Path("/{id}")
public Response getOne(@PathParam("id") Long id) {
return Response.ok(committeeService.retrieveOne(id)).build();
}
@GET
@Path("/current-directors")
public Response getCurrentDirectors() {
return Response.ok(committeeService.getCurrentElectionDirectors()).build();
}
@POST
public Response create(CommitteeRequest request) {
return Response.ok(committeeService.createCommittee(request)).build();
}
@PUT
@Path("/{id}")
public Response update(@PathParam("id") Long id, CommitteeRequest request) {
return Response.ok(committeeService.updateCommittee(id, request)).build();
}
@POST
@Path("/{id}/members")
public Response addMembers(@PathParam("id") Long id, Map<String, List<Long>> body) {
List<Long> ids = body.get("directorAppointmentIds");
return Response.ok(committeeService.addMembers(id, ids)).build();
}
@DELETE
@Path("/{id}/members/{directorAppointmentId}")
public Response removeMember(@PathParam("id") Long id,
@PathParam("directorAppointmentId") Long directorAppointmentId) {
return Response.ok(committeeService.removeMember(id, directorAppointmentId)).build();
}
@POST
@Path("/{id}/dissolve")
public Response dissolve(@PathParam("id") Long id) {
committeeService.dissolveCommittee(id);
return Response.ok().build();
}
}
@@ -0,0 +1,22 @@
package org.apache.fineract.portfolio.election.data;
import lombok.Builder;
import lombok.Data;
import java.time.OffsetDateTime;
import java.util.List;
@Data
@Builder
public class CommitteeData {
private Long id;
private String name;
private String description;
private String status;
private String image;
private OffsetDateTime createdDate;
private Long createdBy;
private OffsetDateTime lastModifiedDate;
private Long lastModifiedBy;
private List<CommitteeMemberData> members;
}
@@ -0,0 +1,18 @@
package org.apache.fineract.portfolio.election.data;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDate;
@Data
@Builder
public class CommitteeMemberData {
private Long id;
private Long directorAppointmentId;
private String candidateName;
private String designation;
private String postName;
private Long memberId;
private LocalDate addedDate;
}
@@ -0,0 +1,13 @@
package org.apache.fineract.portfolio.election.data;
import lombok.Data;
import java.util.List;
@Data
public class CommitteeRequest {
private String name;
private String description;
private String image;
private List<Long> directorAppointmentIds;
}
@@ -0,0 +1,42 @@
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.AbstractAuditableWithUTCDateTimeCustom;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "m_election_committee")
@Getter
@Setter
@NoArgsConstructor
public class Committee extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "name", nullable = false, length = 200)
private String name;
@Column(name = "image", columnDefinition = "TEXT")
private String image;
@Column(name = "description", columnDefinition = "TEXT")
private String description;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 30)
private CommitteeStatus status = CommitteeStatus.ACTIVE;
@OneToMany(mappedBy = "committee", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<CommitteeMember> members = new ArrayList<>();
public static Committee create(String name, String description) {
Committee c = new Committee();
c.name = name;
c.description = description;
c.status = CommitteeStatus.ACTIVE;
return c;
}
}
@@ -0,0 +1,38 @@
package org.apache.fineract.portfolio.election.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDate;
@Entity
@Table(name = "m_election_committee_member")
@Getter
@Setter
@NoArgsConstructor
public class CommitteeMember {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "committee_id", nullable = false)
private Committee committee;
@Column(name = "director_appointment_id", nullable = false)
private Long directorAppointmentId;
@Column(name = "added_date", nullable = false)
private LocalDate addedDate = LocalDate.now();
public static CommitteeMember create(Committee committee, Long directorAppointmentId) {
CommitteeMember m = new CommitteeMember();
m.committee = committee;
m.directorAppointmentId = directorAppointmentId;
m.addedDate = LocalDate.now();
return m;
}
}
@@ -0,0 +1,16 @@
package org.apache.fineract.portfolio.election.domain;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface CommitteeMemberRepository extends JpaRepository<CommitteeMember, Long> {
List<CommitteeMember> findByCommitteeId(Long committeeId);
boolean existsByCommitteeIdAndDirectorAppointmentId(Long committeeId, Long directorAppointmentId);
void deleteByCommitteeIdAndDirectorAppointmentId(Long committeeId, Long directorAppointmentId);
}
@@ -0,0 +1,14 @@
package org.apache.fineract.portfolio.election.domain;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface CommitteeRepository extends JpaRepository<Committee, Long> {
List<Committee> findAllByOrderByCreatedDateDesc();
List<Committee> findByStatusOrderByCreatedDateDesc(CommitteeStatus status);
}
@@ -0,0 +1,5 @@
package org.apache.fineract.portfolio.election.domain;
public enum CommitteeStatus {
ACTIVE, DISSOLVED
}
@@ -22,6 +22,8 @@ public interface DirectorAppointmentRepository extends JpaRepository<DirectorApp
Page<DirectorAppointment> findByElectionIdAndStatus(Long electionId, DirectorAppointmentStatus status, Pageable pageable);
List<DirectorAppointment> findByElectionIdAndStatus(Long electionId, DirectorAppointmentStatus status);
List<DirectorAppointment> findByElectionIdAndCandidateId(Long electionId, Long candidateId);
boolean existsByElectionIdAndCandidateId(Long electionId, Long candidateId);
@@ -36,6 +36,6 @@ public interface ElectionConfigRepository extends JpaRepository<ElectionConfig,
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);
@Query(value = "SELECT * FROM m_election_config WHERE status = 'RESULT_DECLARED' AND duration_years IS NOT NULL AND election_date + CAST(duration_years || ' years' AS INTERVAL) > ?1 LIMIT 1", nativeQuery = true)
Optional<ElectionConfig> findFirstWithActiveDuration(LocalDate today);
}
@@ -0,0 +1,26 @@
package org.apache.fineract.portfolio.election.service;
import org.apache.fineract.portfolio.election.data.CommitteeData;
import org.apache.fineract.portfolio.election.data.CommitteeRequest;
import org.apache.fineract.portfolio.election.data.DirectorAppointmentData;
import java.util.List;
public interface CommitteeService {
CommitteeData createCommittee(CommitteeRequest request);
CommitteeData updateCommittee(Long id, CommitteeRequest request);
void dissolveCommittee(Long id);
List<CommitteeData> retrieveAll();
CommitteeData retrieveOne(Long id);
CommitteeData addMembers(Long committeeId, List<Long> directorAppointmentIds);
CommitteeData removeMember(Long committeeId, Long directorAppointmentId);
List<DirectorAppointmentData> getCurrentElectionDirectors();
}
@@ -0,0 +1,185 @@
package org.apache.fineract.portfolio.election.service;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.election.data.CommitteeData;
import org.apache.fineract.portfolio.election.data.CommitteeMemberData;
import org.apache.fineract.portfolio.election.data.CommitteeRequest;
import org.apache.fineract.portfolio.election.data.DirectorAppointmentData;
import org.apache.fineract.portfolio.election.domain.Committee;
import org.apache.fineract.portfolio.election.domain.CommitteeMember;
import org.apache.fineract.portfolio.election.domain.CommitteeMemberRepository;
import org.apache.fineract.portfolio.election.domain.CommitteeRepository;
import org.apache.fineract.portfolio.election.domain.CommitteeStatus;
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.ElectionCandidateRepository;
import org.apache.fineract.portfolio.election.domain.ElectionConfigRepository;
import org.apache.fineract.portfolio.election.domain.ElectionStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class CommitteeServiceImpl implements CommitteeService {
private final CommitteeRepository committeeRepository;
private final CommitteeMemberRepository memberRepository;
private final DirectorAppointmentRepository directorRepository;
private final ElectionCandidateRepository candidateRepository;
private final ElectionConfigRepository electionConfigRepository;
@Override
@Transactional
public CommitteeData createCommittee(CommitteeRequest request) {
Committee committee = Committee.create(request.getName(), request.getDescription());
committee.setImage(request.getImage());
committeeRepository.save(committee);
if (request.getDirectorAppointmentIds() != null) {
for (Long dirId : request.getDirectorAppointmentIds()) {
if (!memberRepository.existsByCommitteeIdAndDirectorAppointmentId(committee.getId(), dirId)) {
committee.getMembers().add(CommitteeMember.create(committee, dirId));
}
}
committeeRepository.save(committee);
}
return toData(committee);
}
@Override
@Transactional
public CommitteeData updateCommittee(Long id, CommitteeRequest request) {
Committee committee = findOrThrow(id);
if (request.getName() != null) committee.setName(request.getName());
if (request.getDescription() != null) committee.setDescription(request.getDescription());
committee.setImage(request.getImage());
return toData(committeeRepository.save(committee));
}
@Override
@Transactional
public void dissolveCommittee(Long id) {
Committee committee = findOrThrow(id);
committee.setStatus(CommitteeStatus.DISSOLVED);
committeeRepository.save(committee);
}
@Override
public List<CommitteeData> retrieveAll() {
return committeeRepository.findAllByOrderByCreatedDateDesc()
.stream().map(this::toData).toList();
}
@Override
public CommitteeData retrieveOne(Long id) {
return toData(findOrThrow(id));
}
@Override
@Transactional
public CommitteeData addMembers(Long committeeId, List<Long> directorAppointmentIds) {
Committee committee = findOrThrow(committeeId);
for (Long dirId : directorAppointmentIds) {
if (!memberRepository.existsByCommitteeIdAndDirectorAppointmentId(committeeId, dirId)) {
committee.getMembers().add(CommitteeMember.create(committee, dirId));
}
}
return toData(committeeRepository.save(committee));
}
@Override
@Transactional
public CommitteeData removeMember(Long committeeId, Long directorAppointmentId) {
findOrThrow(committeeId);
memberRepository.deleteByCommitteeIdAndDirectorAppointmentId(committeeId, directorAppointmentId);
return toData(findOrThrow(committeeId));
}
@Override
public List<DirectorAppointmentData> getCurrentElectionDirectors() {
var currentElection = electionConfigRepository.findFirstWithActiveDuration(LocalDate.now());
// fallback: most recent RESULT_DECLARED election if none has active duration
if (currentElection.isEmpty()) {
currentElection = electionConfigRepository
.findByStatus(ElectionStatus.RESULT_DECLARED)
.stream()
.max(Comparator.comparing(e -> e.getElectionDate() != null ? e.getElectionDate() : LocalDate.MIN));
}
if (currentElection.isEmpty()) return List.of();
Long electionId = currentElection.get().getId();
List<DirectorAppointment> directors = directorRepository
.findByElectionIdAndStatus(electionId, DirectorAppointmentStatus.ACTIVE);
Set<Long> candidateIds = directors.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 directors.stream().map(d -> DirectorAppointmentData.builder()
.id(d.getId())
.candidateName(d.getCandidateName())
.designation(d.getDesignation())
.postName(d.getPostName())
.memberId(candidateMemberMap.get(d.getCandidateId()))
.build()).toList();
}
private Committee findOrThrow(Long id) {
return committeeRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Committee not found: " + id));
}
private CommitteeData toData(Committee c) {
List<Long> dirIds = c.getMembers().stream()
.map(CommitteeMember::getDirectorAppointmentId).toList();
Map<Long, DirectorAppointment> dirMap = directorRepository.findAllById(dirIds)
.stream().collect(Collectors.toMap(DirectorAppointment::getId, d -> d));
Set<Long> candidateIds = dirMap.values().stream()
.map(DirectorAppointment::getCandidateId).collect(Collectors.toSet());
Map<Long, Long> candidateMemberMap = candidateRepository.findAllById(candidateIds)
.stream()
.filter(e -> e.getNomination() != null && e.getNomination().getMemberId() != null)
.collect(Collectors.toMap(e -> e.getId(), e -> e.getNomination().getMemberId()));
List<CommitteeMemberData> members = c.getMembers().stream().map(m -> {
DirectorAppointment dir = dirMap.get(m.getDirectorAppointmentId());
return CommitteeMemberData.builder()
.id(m.getId())
.directorAppointmentId(m.getDirectorAppointmentId())
.candidateName(dir != null ? dir.getCandidateName() : null)
.designation(dir != null ? dir.getDesignation() : null)
.postName(dir != null ? dir.getPostName() : null)
.memberId(dir != null ? candidateMemberMap.get(dir.getCandidateId()) : null)
.addedDate(m.getAddedDate())
.build();
}).toList();
return CommitteeData.builder()
.id(c.getId())
.name(c.getName())
.description(c.getDescription())
.status(c.getStatus().name())
.image(c.getImage())
.createdDate(c.getCreatedDate().orElse(null))
.createdBy(c.getCreatedBy().orElse(null))
.lastModifiedDate(c.getLastModifiedDate().orElse(null))
.lastModifiedBy(c.getLastModifiedBy().orElse(null))
.members(members)
.build();
}
}
@@ -376,4 +376,6 @@
<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"/>
<include file="parts/0349_election_committee.xml" relativeToChangelogFile="true"/>
<include file="parts/0350_committee_image.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,67 @@
<?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="0349-election-committee" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_election_committee"/></not>
</preConditions>
<createTable tableName="m_election_committee">
<column name="id" type="BIGSERIAL" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="description" type="TEXT"/>
<column name="status" type="VARCHAR(30)" defaultValue="ACTIVE">
<constraints nullable="false"/>
</column>
<column name="created_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
<column name="last_modified_by" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="last_modified_on_utc" type="TIMESTAMP WITH TIME ZONE">
<constraints nullable="false"/>
</column>
</createTable>
<createTable tableName="m_election_committee_member">
<column name="id" type="BIGSERIAL" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="committee_id" type="BIGINT">
<constraints nullable="false"
foreignKeyName="fk_committee_member_committee"
references="m_election_committee(id)"/>
</column>
<column name="director_appointment_id" type="BIGINT">
<constraints nullable="false"
foreignKeyName="fk_committee_member_director"
references="m_election_director_appointment(id)"/>
</column>
<column name="added_date" type="DATE" defaultValueComputed="CURRENT_DATE">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex indexName="idx_committee_member_committee"
tableName="m_election_committee_member">
<column name="committee_id"/>
</createIndex>
<createIndex indexName="idx_committee_status"
tableName="m_election_committee">
<column name="status"/>
</createIndex>
</changeSet>
</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.1.xsd">
<changeSet id="0350-committee-image" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_election_committee" columnName="image"/></not>
</preConditions>
<addColumn tableName="m_election_committee">
<column name="image" type="TEXT"/>
</addColumn>
</changeSet>
</databaseChangeLog>