diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/CommitteeApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/CommitteeApiResource.java new file mode 100644 index 000000000..04b2754b9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/CommitteeApiResource.java @@ -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> body) { + List 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeData.java new file mode 100644 index 000000000..951c160f0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeData.java @@ -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 members; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeMemberData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeMemberData.java new file mode 100644 index 000000000..54d644be8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeMemberData.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeRequest.java new file mode 100644 index 000000000..29d207baf --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeRequest.java @@ -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 directorAppointmentIds; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/Committee.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/Committee.java new file mode 100644 index 000000000..6a394a65d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/Committee.java @@ -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 { + + @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 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMember.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMember.java new file mode 100644 index 000000000..b65c19f48 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMember.java @@ -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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMemberRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMemberRepository.java new file mode 100644 index 000000000..7c830aa12 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMemberRepository.java @@ -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 { + + List findByCommitteeId(Long committeeId); + + boolean existsByCommitteeIdAndDirectorAppointmentId(Long committeeId, Long directorAppointmentId); + + void deleteByCommitteeIdAndDirectorAppointmentId(Long committeeId, Long directorAppointmentId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeRepository.java new file mode 100644 index 000000000..314cab0cf --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeRepository.java @@ -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 { + + List findAllByOrderByCreatedDateDesc(); + + List findByStatusOrderByCreatedDateDesc(CommitteeStatus status); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeStatus.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeStatus.java new file mode 100644 index 000000000..58281cf12 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeStatus.java @@ -0,0 +1,5 @@ +package org.apache.fineract.portfolio.election.domain; + +public enum CommitteeStatus { + ACTIVE, DISSOLVED +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentRepository.java index 4e61825f8..6736746eb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/DirectorAppointmentRepository.java @@ -22,6 +22,8 @@ public interface DirectorAppointmentRepository extends JpaRepository findByElectionIdAndStatus(Long electionId, DirectorAppointmentStatus status, Pageable pageable); + List findByElectionIdAndStatus(Long electionId, DirectorAppointmentStatus status); + List findByElectionIdAndCandidateId(Long electionId, Long candidateId); boolean existsByElectionIdAndCandidateId(Long electionId, Long candidateId); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfigRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfigRepository.java index b7dcd33a5..f25f679f8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfigRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionConfigRepository.java @@ -36,6 +36,6 @@ public interface ElectionConfigRepository extends JpaRepository 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 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 findFirstWithActiveDuration(LocalDate today); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeService.java new file mode 100644 index 000000000..8c19fbfa6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeService.java @@ -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 retrieveAll(); + + CommitteeData retrieveOne(Long id); + + CommitteeData addMembers(Long committeeId, List directorAppointmentIds); + + CommitteeData removeMember(Long committeeId, Long directorAppointmentId); + + List getCurrentElectionDirectors(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeServiceImpl.java new file mode 100644 index 000000000..6f7fa71e5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeServiceImpl.java @@ -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 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 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 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 directors = directorRepository + .findByElectionIdAndStatus(electionId, DirectorAppointmentStatus.ACTIVE); + + Set candidateIds = directors.stream() + .map(DirectorAppointment::getCandidateId).collect(Collectors.toSet()); + Map 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 dirIds = c.getMembers().stream() + .map(CommitteeMember::getDirectorAppointmentId).toList(); + + Map dirMap = directorRepository.findAllById(dirIds) + .stream().collect(Collectors.toMap(DirectorAppointment::getId, d -> d)); + + Set candidateIds = dirMap.values().stream() + .map(DirectorAppointment::getCandidateId).collect(Collectors.toSet()); + Map 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 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(); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 6ba2ec7bf..71eff5283 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -376,4 +376,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0349_election_committee.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0349_election_committee.xml new file mode 100644 index 000000000..3fdee5b38 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0349_election_committee.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0350_committee_image.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0350_committee_image.xml new file mode 100644 index 000000000..909d210db --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0350_committee_image.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + +