From 934a51c9eefbc8fee317096a149378460d47fb23 Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Tue, 7 Jul 2026 18:26:14 +0530 Subject: [PATCH] feat(election): dynamic designations from m_code, email-based voting, vote remark field - DesignationService reads designation limits and mandatory flags from DirectorDesignation m_code_value - DirectorAppointmentServiceImpl: replaced hardcoded DESIGNATION_LIMITS map with dynamic DB fetch - CommitteeServiceImpl: replaced hardcoded Chairman/Vice Chairman check with dynamic mandatory designation query - New GET /v1/election/directors/designations endpoint returns all active designations - Email-based voting module: MeetingPublicVoteApiResource, MeetingVoteAdminApiResource, MeetingVoteService - MeetingMemberVote: added remark (TEXT) column for member vote remarks - AgendaVoteRequest: added remark field to carry voice/typed remarks per agenda item - Liquibase migrations 0354 (vote/resolution tables), 0355 (DirectorDesignation code), 0356 (vote remark column) - SecurityConfig: permit public voting endpoints without authentication Co-Authored-By: Claude Sonnet 4.6 --- .../core/config/SecurityConfig.java | 3 + .../api/DirectorAppointmentApiResource.java | 9 + .../api/MeetingPublicVoteApiResource.java | 53 +++ .../api/MeetingVoteAdminApiResource.java | 64 +++ .../data/AgendaResolutionRequest.java | 10 + .../election/data/AgendaVoteRequest.java | 10 + .../election/data/AgendaVoteSummaryData.java | 19 + .../election/data/DesignationData.java | 14 + .../data/MeetingVoteSubmitRequest.java | 10 + .../election/data/MeetingVotingPageData.java | 27 ++ .../election/domain/AgendaResolution.java | 5 + .../portfolio/election/domain/AgendaVote.java | 5 + .../domain/DirectorAppointmentRepository.java | 7 + .../domain/MeetingAgendaResolution.java | 47 ++ .../MeetingAgendaResolutionRepository.java | 19 + .../election/domain/MeetingMemberVote.java | 53 +++ .../domain/MeetingMemberVoteRepository.java | 24 ++ .../election/domain/MeetingVoteToken.java | 55 +++ .../domain/MeetingVoteTokenRepository.java | 22 + .../service/CommitteeMeetingServiceImpl.java | 12 +- .../service/CommitteeServiceImpl.java | 29 ++ .../election/service/DesignationService.java | 15 + .../service/DesignationServiceImpl.java | 60 +++ .../DirectorAppointmentServiceImpl.java | 26 ++ .../election/service/MeetingVoteService.java | 400 ++++++++++++++++++ .../src/main/resources/application.properties | 3 + .../db/changelog/tenant/changelog-tenant.xml | 3 + .../parts/0354_meeting_vote_resolution.xml | 143 +++++++ .../parts/0355_director_designation_code.xml | 35 ++ .../tenant/parts/0356_meeting_vote_remark.xml | 15 + 30 files changed, 1196 insertions(+), 1 deletion(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingPublicVoteApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingVoteAdminApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/AgendaResolutionRequest.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/AgendaVoteRequest.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/AgendaVoteSummaryData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/DesignationData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/MeetingVoteSubmitRequest.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/MeetingVotingPageData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/AgendaResolution.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/AgendaVote.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingAgendaResolution.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingAgendaResolutionRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingMemberVote.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingMemberVoteRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingVoteToken.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingVoteTokenRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DesignationService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DesignationServiceImpl.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/MeetingVoteService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0354_meeting_vote_resolution.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0355_director_designation_code.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0356_meeting_vote_remark.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java index 353b5dd29..c8ffb4238 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/core/config/SecurityConfig.java @@ -146,6 +146,9 @@ public class SecurityConfig { auth.requestMatchers(API_MATCHER.matcher(HttpMethod.OPTIONS, "/api/**")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/echo")).permitAll() .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/authentication")).permitAll() + // Meeting vote public endpoints — secured by UUID token in the URL path + .requestMatchers(API_MATCHER.matcher(HttpMethod.GET, "/api/*/public/meeting-vote/**")).permitAll() + .requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/public/meeting-vote/**")).permitAll() // IMPS receiver — Fineract acts as the bank-side endpoint under // IMPS_BANK_API_SPEC_CBS_1.0 §6.2.x. LincPay (the switch) calls these without // a Fineract Bearer token; security is enforced at the JWT envelope diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/DirectorAppointmentApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/DirectorAppointmentApiResource.java index aa4539395..9e422d57c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/DirectorAppointmentApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/DirectorAppointmentApiResource.java @@ -11,6 +11,7 @@ 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.DesignationService; import org.apache.fineract.portfolio.election.service.DirectorAppointmentService; import org.apache.fineract.portfolio.election.service.ElectionAuditService; import org.springframework.stereotype.Component; @@ -27,6 +28,14 @@ public class DirectorAppointmentApiResource { private final DirectorAppointmentService service; private final ElectionAuditService auditService; + private final DesignationService designationService; + + @GET + @Path("/designations") + @Operation(summary = "List available director designations from m_code") + public Response designations() { + return Response.ok(designationService.retrieveAll()).build(); + } @GET @Path("/pending") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingPublicVoteApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingPublicVoteApiResource.java new file mode 100644 index 000000000..428cff9fd --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingPublicVoteApiResource.java @@ -0,0 +1,53 @@ +package org.apache.fineract.portfolio.election.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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.MeetingVoteSubmitRequest; +import org.apache.fineract.portfolio.election.service.MeetingVoteService; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +@Path("/v1/public/meeting-vote") +@Tag(name = "Meeting Vote (Public)", description = "Token-based agenda voting — no login required") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +public class MeetingPublicVoteApiResource { + + private final MeetingVoteService voteService; + + @GET + @Path("/{token}") + @Operation(summary = "Get voting page data for a member via secure token") + public Response getVotingPage(@PathParam("token") String token) { + try { + return Response.ok(voteService.getVotingPage(token)).build(); + } catch (IllegalArgumentException e) { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", e.getMessage())).build(); + } + } + + @POST + @Path("/{token}") + @Operation(summary = "Submit votes for all agenda items via secure token") + public Response submitVotes(@PathParam("token") String token, + MeetingVoteSubmitRequest request) { + try { + voteService.submitVotes(token, request); + return Response.ok(Map.of("message", "Votes recorded successfully. Thank you!")).build(); + } catch (IllegalStateException e) { + return Response.status(Response.Status.GONE) + .entity(Map.of("error", e.getMessage())).build(); + } catch (IllegalArgumentException e) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", e.getMessage())).build(); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingVoteAdminApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingVoteAdminApiResource.java new file mode 100644 index 000000000..a353257c8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/MeetingVoteAdminApiResource.java @@ -0,0 +1,64 @@ +package org.apache.fineract.portfolio.election.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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.AgendaResolutionRequest; +import org.apache.fineract.portfolio.election.service.MeetingVoteService; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +@Component +@Path("/v1/election/meetings/{meetingId}/votes") +@Tag(name = "Meeting Vote Admin", description = "Admin: view vote summary and record final resolutions") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +@RequiredArgsConstructor +public class MeetingVoteAdminApiResource { + + private final MeetingVoteService voteService; + + @GET + @Path("/summary") + @Operation(summary = "Get vote summary per agenda item for a meeting") + public Response getSummary(@PathParam("meetingId") Long meetingId) { + try { + return Response.ok(voteService.getVoteSummary(meetingId)).build(); + } catch (IllegalArgumentException e) { + return Response.status(Response.Status.NOT_FOUND) + .entity(Map.of("error", e.getMessage())).build(); + } + } + + @POST + @Path("/resend") + @Operation(summary = "Resend voting emails to all committee members for a completed meeting") + public Response resendVotingEmails(@PathParam("meetingId") Long meetingId) { + try { + voteService.sendVotingEmails(meetingId); + return Response.ok(Map.of("message", "Voting emails resent to all members")).build(); + } catch (IllegalArgumentException e) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", e.getMessage())).build(); + } + } + + @PUT + @Path("/resolutions") + @Operation(summary = "Admin saves final resolution for each agenda item and sends meeting minutes email") + public Response saveResolutions(@PathParam("meetingId") Long meetingId, + List requests) { + try { + voteService.saveResolutions(meetingId, requests); + return Response.ok(Map.of("message", "Resolutions saved and meeting minutes sent")).build(); + } catch (IllegalArgumentException e) { + return Response.status(Response.Status.BAD_REQUEST) + .entity(Map.of("error", e.getMessage())).build(); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/AgendaResolutionRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/AgendaResolutionRequest.java new file mode 100644 index 000000000..2e2bf1937 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/AgendaResolutionRequest.java @@ -0,0 +1,10 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.Data; + +@Data +public class AgendaResolutionRequest { + private Long agendaId; + private String resolution; // APPROVED, REJECTED, DEFERRED + private String discussionNotes; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/AgendaVoteRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/AgendaVoteRequest.java new file mode 100644 index 000000000..0961e8c76 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/AgendaVoteRequest.java @@ -0,0 +1,10 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.Data; + +@Data +public class AgendaVoteRequest { + private Long agendaId; + private String vote; // APPROVED, REJECTED, ABSTAIN + private String remark; // optional member remark (text or voice transcript) +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/AgendaVoteSummaryData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/AgendaVoteSummaryData.java new file mode 100644 index 000000000..00530a448 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/AgendaVoteSummaryData.java @@ -0,0 +1,19 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class AgendaVoteSummaryData { + private Long agendaId; + private String agendaItem; + private int displayOrder; + private long approvedCount; + private long rejectedCount; + private long abstainCount; + private long pendingCount; + private long totalMembers; + private String resolution; // null until admin records it + private String discussionNotes; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/DesignationData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/DesignationData.java new file mode 100644 index 000000000..8b8c4425d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/DesignationData.java @@ -0,0 +1,14 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class DesignationData { + private String name; + /** null means no limit */ + private Integer limitCount; + /** true = must be present when creating a committee */ + private boolean mandatory; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/MeetingVoteSubmitRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/MeetingVoteSubmitRequest.java new file mode 100644 index 000000000..1f9996f2b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/MeetingVoteSubmitRequest.java @@ -0,0 +1,10 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.Data; + +import java.util.List; + +@Data +public class MeetingVoteSubmitRequest { + private List votes; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/MeetingVotingPageData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/MeetingVotingPageData.java new file mode 100644 index 000000000..9b1b5e591 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/MeetingVotingPageData.java @@ -0,0 +1,27 @@ +package org.apache.fineract.portfolio.election.data; + +import lombok.AllArgsConstructor; +import lombok.Data; + +import java.util.List; + +@Data +@AllArgsConstructor +public class MeetingVotingPageData { + private Long meetingId; + private String meetingTitle; + private String meetingDate; + private String committeeName; + private String memberName; + private boolean votingClosed; // token used or expired + private List agendaItems; + + @Data + @AllArgsConstructor + public static class AgendaItemVoteData { + private Long agendaId; + private String agendaItem; + private int displayOrder; + private String currentVote; // null if not yet voted + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/AgendaResolution.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/AgendaResolution.java new file mode 100644 index 000000000..be9ee31e8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/AgendaResolution.java @@ -0,0 +1,5 @@ +package org.apache.fineract.portfolio.election.domain; + +public enum AgendaResolution { + APPROVED, REJECTED, DEFERRED +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/AgendaVote.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/AgendaVote.java new file mode 100644 index 000000000..86b471d2e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/AgendaVote.java @@ -0,0 +1,5 @@ +package org.apache.fineract.portfolio.election.domain; + +public enum AgendaVote { + APPROVED, REJECTED, ABSTAIN +} 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 6736746eb..d4e8e02c9 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 @@ -28,6 +28,13 @@ public interface DirectorAppointmentRepository extends JpaRepository { + + List findByMeetingId(Long meetingId); + + @Modifying + @Query("DELETE FROM MeetingAgendaResolution r WHERE r.meetingId = :meetingId") + void deleteByMeetingId(@Param("meetingId") Long meetingId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingMemberVote.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingMemberVote.java new file mode 100644 index 000000000..b8a6a4fba --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingMemberVote.java @@ -0,0 +1,53 @@ +package org.apache.fineract.portfolio.election.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "m_election_meeting_member_vote", + uniqueConstraints = @UniqueConstraint( + name = "uq_member_vote_agenda_member", + columnNames = {"agenda_id", "member_id"})) +@Getter +@Setter +@NoArgsConstructor +public class MeetingMemberVote { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "meeting_id", nullable = false) + private Long meetingId; + + @Column(name = "agenda_id", nullable = false) + private Long agendaId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "vote", nullable = false, length = 20) + private AgendaVote vote; + + @Column(name = "voted_at", nullable = false) + private LocalDateTime votedAt; + + @Column(name = "remark", columnDefinition = "TEXT") + private String remark; + + public static MeetingMemberVote of(Long meetingId, Long agendaId, Long memberId, AgendaVote vote, String remark) { + MeetingMemberVote v = new MeetingMemberVote(); + v.meetingId = meetingId; + v.agendaId = agendaId; + v.memberId = memberId; + v.vote = vote; + v.votedAt = LocalDateTime.now(); + v.remark = (remark != null && !remark.isBlank()) ? remark.trim() : null; + return v; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingMemberVoteRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingMemberVoteRepository.java new file mode 100644 index 000000000..69a4c66a6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingMemberVoteRepository.java @@ -0,0 +1,24 @@ +package org.apache.fineract.portfolio.election.domain; + +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.util.List; +import java.util.Optional; + +@Repository +public interface MeetingMemberVoteRepository extends JpaRepository { + + List findByMeetingId(Long meetingId); + + List findByMeetingIdAndMemberId(Long meetingId, Long memberId); + + Optional findByAgendaIdAndMemberId(Long agendaId, Long memberId); + + @Modifying + @Query("DELETE FROM MeetingMemberVote v WHERE v.meetingId = :meetingId AND v.memberId = :memberId") + void deleteByMeetingIdAndMemberId(@Param("meetingId") Long meetingId, @Param("memberId") Long memberId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingVoteToken.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingVoteToken.java new file mode 100644 index 000000000..43c873cba --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingVoteToken.java @@ -0,0 +1,55 @@ +package org.apache.fineract.portfolio.election.domain; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "m_election_meeting_vote_token") +@Getter +@Setter +@NoArgsConstructor +public class MeetingVoteToken { + + @Id + @Column(name = "token", length = 36, nullable = false) + private String token; + + @Column(name = "meeting_id", nullable = false) + private Long meetingId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Column(name = "member_name", nullable = false, length = 200) + private String memberName; + + @Column(name = "is_used", nullable = false) + private boolean used = false; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + public static MeetingVoteToken generate(Long meetingId, Long memberId, String memberName, int validDays) { + MeetingVoteToken t = new MeetingVoteToken(); + t.token = UUID.randomUUID().toString(); + t.meetingId = meetingId; + t.memberId = memberId; + t.memberName = memberName; + t.used = false; + t.createdAt = LocalDateTime.now(); + t.expiresAt = t.createdAt.plusDays(validDays); + return t; + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingVoteTokenRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingVoteTokenRepository.java new file mode 100644 index 000000000..561ae1548 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/MeetingVoteTokenRepository.java @@ -0,0 +1,22 @@ +package org.apache.fineract.portfolio.election.domain; + +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.util.List; +import java.util.Optional; + +@Repository +public interface MeetingVoteTokenRepository extends JpaRepository { + + Optional findByTokenAndUsedFalse(String token); + + List findByMeetingId(Long meetingId); + + @Modifying + @Query("DELETE FROM MeetingVoteToken t WHERE t.meetingId = :meetingId") + void deleteByMeetingId(@Param("meetingId") Long meetingId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeMeetingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeMeetingServiceImpl.java index ddd5bd20e..f63702e0e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeMeetingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeMeetingServiceImpl.java @@ -23,6 +23,7 @@ import org.apache.fineract.portfolio.election.domain.ElectionCandidate; import org.apache.fineract.portfolio.election.domain.ElectionCandidateRepository; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import org.apache.fineract.portfolio.election.service.MeetingVoteService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -45,6 +46,7 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService { private final ElectionCandidateRepository electionCandidateRepository; private final ClientRepository clientRepository; private final EmailSenderService emailSenderService; + private final MeetingVoteService meetingVoteService; @PersistenceContext private EntityManager entityManager; @@ -122,7 +124,15 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService { throw new IllegalStateException("Meeting is already " + meeting.getStatus().name() + " and cannot be changed"); } meeting.setStatus(newStatus); - return toData(repository.save(meeting)); + CommitteeMeetingData result = toData(repository.save(meeting)); + if (newStatus == CommitteeMeetingStatus.COMPLETED) { + try { + meetingVoteService.sendVotingEmails(meetingId); + } catch (Exception e) { + log.warn("Failed to send voting emails for meeting {}: {}", meetingId, e.getMessage()); + } + } + return result; } @Override 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 index 7a370b0e0..1edaa3044 100644 --- 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 @@ -37,10 +37,13 @@ public class CommitteeServiceImpl implements CommitteeService { private final ElectionCandidateRepository candidateRepository; private final ElectionConfigRepository electionConfigRepository; private final ElectionAuditService auditService; + private final DesignationService designationService; @Override @Transactional public CommitteeData createCommittee(CommitteeRequest request) { + validateRequiredDesignationsPresent(request.getDirectorAppointmentIds()); + Committee committee = Committee.create(request.getName(), request.getDescription()); committee.setImage(request.getImage()); committeeRepository.save(committee); @@ -226,4 +229,30 @@ public class CommitteeServiceImpl implements CommitteeService { .members(members) .build(); } + + /** + * Validates that the committee includes at least one active member for each mandatory designation + * (driven by is_mandatory flag in the DirectorDesignation m_code entry). + */ + private void validateRequiredDesignationsPresent(List directorAppointmentIds) { + Set required = designationService.getMandatoryDesignations(); + if (required.isEmpty()) return; + + if (directorAppointmentIds == null || directorAppointmentIds.isEmpty()) { + throw new IllegalStateException( + "Committee must include active members for: " + String.join(", ", required)); + } + List directors = directorRepository.findAllById(directorAppointmentIds); + Set present = directors.stream() + .filter(d -> d.getStatus() == DirectorAppointmentStatus.ACTIVE) + .map(DirectorAppointment::getDesignation) + .collect(Collectors.toSet()); + Set missing = required.stream() + .filter(r -> !present.contains(r)) + .collect(Collectors.toSet()); + if (!missing.isEmpty()) { + throw new IllegalStateException( + "Committee must include an active: " + String.join(", ", missing)); + } + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DesignationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DesignationService.java new file mode 100644 index 000000000..7643c86ab --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DesignationService.java @@ -0,0 +1,15 @@ +package org.apache.fineract.portfolio.election.service; + +import org.apache.fineract.portfolio.election.data.DesignationData; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +public interface DesignationService { + List retrieveAll(); + /** designation name → max active count (only entries with a configured limit) */ + Map getDesignationLimits(); + /** designations that must be present when creating a new committee */ + Set getMandatoryDesignations(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DesignationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DesignationServiceImpl.java new file mode 100644 index 000000000..120cbb8a2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DesignationServiceImpl.java @@ -0,0 +1,60 @@ +package org.apache.fineract.portfolio.election.service; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.election.data.DesignationData; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class DesignationServiceImpl implements DesignationService { + + static final String CODE_NAME = "DirectorDesignation"; + + private final NamedParameterJdbcTemplate jdbcTemplate; + + @Override + public List retrieveAll() { + String sql = "SELECT cv.code_value, cv.code_score, cv.is_mandatory " + + "FROM m_code_value cv " + + "JOIN m_code c ON cv.code_id = c.id " + + "WHERE c.code_name = :codeName AND cv.is_active = true " + + "ORDER BY cv.order_position"; + return jdbcTemplate.query(sql, + new MapSqlParameterSource("codeName", CODE_NAME), + (rs, rowNum) -> DesignationData.builder() + .name(rs.getString("code_value")) + .limitCount(rs.getObject("code_score", Integer.class)) + .mandatory(rs.getBoolean("is_mandatory")) + .build()); + } + + @Override + public Map getDesignationLimits() { + Map limits = new HashMap<>(); + for (DesignationData d : retrieveAll()) { + if (d.getLimitCount() != null) { + limits.put(d.getName(), d.getLimitCount()); + } + } + return limits; + } + + @Override + public Set getMandatoryDesignations() { + Set mandatory = new HashSet<>(); + for (DesignationData d : retrieveAll()) { + if (d.isMandatory()) { + mandatory.add(d.getName()); + } + } + return mandatory; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentServiceImpl.java index f04c2f7df..a6950b779 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/DirectorAppointmentServiceImpl.java @@ -40,12 +40,14 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic private final ElectionConfigRepository electionConfigRepository; private final ElectionCandidateRepository candidateRepository; private final ElectionAuditService auditService; + private final DesignationService designationService; @Override @Transactional public Long appoint(DirectorAppointmentRequest req) { overrideTenureEndFromElection(req); validateTenureDates(req); + validateDesignationLimit(req.getDesignation(), null); if (repository.existsByElectionIdAndCandidateId(req.getElectionId(), req.getCandidateId())) { throw new IllegalStateException( "Candidate " + req.getCandidateId() + " is already appointed for election " + req.getElectionId()); @@ -81,6 +83,10 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic if (req.getElectionId() == null) req.setElectionId(existing.getElectionId()); overrideTenureEndFromElection(req); validateTenureDates(req); + // Only validate designation limit if designation is being changed + if (req.getDesignation() != null && !req.getDesignation().equals(existing.getDesignation())) { + validateDesignationLimit(req.getDesignation(), id); + } boolean isRemoval = "REMOVED".equalsIgnoreCase(req.getStatus()) && existing.getStatus() != DirectorAppointmentStatus.REMOVED; applyFields(existing, req); @@ -198,6 +204,26 @@ public class DirectorAppointmentServiceImpl implements DirectorAppointmentServic } } + /** + * Validates that the given designation has not exceeded its allowed count among ACTIVE directors. + * Limits are read dynamically from the DirectorDesignation m_code entry. + * @param designation the designation to check + * @param excludeId if non-null, excludes this director ID from the count (self-exclusion for updates) + */ + private void validateDesignationLimit(String designation, Long excludeId) { + if (designation == null) return; + Map limits = designationService.getDesignationLimits(); + if (!limits.containsKey(designation)) return; + int limit = limits.get(designation); + int current = (excludeId != null) + ? repository.countByDesignationAndStatusExcluding(designation, DirectorAppointmentStatus.ACTIVE, excludeId) + : repository.countByDesignationAndStatus(designation, DirectorAppointmentStatus.ACTIVE); + if (current >= limit) { + throw new IllegalStateException( + "Designation limit reached: only " + limit + " '" + designation + "' allowed at a time. Currently " + current + " active."); + } + } + private DirectorAppointment findOrThrow(Long id) { return repository.findById(id).orElseThrow( () -> new AbstractPlatformResourceNotFoundException("error.msg.director.appointment.not.found", diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/MeetingVoteService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/MeetingVoteService.java new file mode 100644 index 000000000..422cc0e0f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/MeetingVoteService.java @@ -0,0 +1,400 @@ +package org.apache.fineract.portfolio.election.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.email.service.EmailSenderService; +import org.springframework.beans.factory.annotation.Value; +import org.apache.fineract.portfolio.client.domain.ClientRepository; +import org.apache.fineract.portfolio.election.data.*; +import org.apache.fineract.portfolio.election.domain.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MeetingVoteService { + + private final MeetingVoteTokenRepository tokenRepository; + private final MeetingMemberVoteRepository voteRepository; + private final MeetingAgendaResolutionRepository resolutionRepository; + private final CommitteeMeetingRepository meetingRepository; + private final CommitteeMemberRepository memberRepository; + private final CommitteeRepository committeeRepository; + private final DirectorAppointmentRepository directorAppointmentRepository; + private final ElectionCandidateRepository electionCandidateRepository; + private final ClientRepository clientRepository; + private final EmailSenderService emailSenderService; + + @Value("${cbs.frontend.base-url:https://app.cbsnexora.com}") + private String frontendBaseUrl; + + // ── Token resolution page ────────────────────────────────────────────────── + + @Transactional(readOnly = true) + public MeetingVotingPageData getVotingPage(String token) { + MeetingVoteToken voteToken = tokenRepository.findById(token) + .orElseThrow(() -> new IllegalArgumentException("Invalid voting link")); + + CommitteeMeeting meeting = meetingRepository.findByIdAndIsDeletedFalse(voteToken.getMeetingId()) + .orElseThrow(() -> new IllegalArgumentException("Meeting not found")); + + String committeeName = committeeRepository.findById(meeting.getCommitteeId()) + .map(Committee::getName).orElse(""); + + boolean closed = voteToken.isUsed() || voteToken.isExpired(); + + Map existingVotes = voteRepository + .findByMeetingIdAndMemberId(meeting.getId(), voteToken.getMemberId()) + .stream().collect(Collectors.toMap( + MeetingMemberVote::getAgendaId, + v -> v.getVote().name())); + + List items = meeting.getAgendaItems().stream() + .map(a -> new MeetingVotingPageData.AgendaItemVoteData( + a.getId(), a.getAgendaItem(), a.getDisplayOrder(), + existingVotes.get(a.getId()))) + .toList(); + + String dateStr = meeting.getMeetingDate() != null + ? meeting.getMeetingDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")) : ""; + + return new MeetingVotingPageData( + meeting.getId(), meeting.getTitle(), dateStr, + committeeName, voteToken.getMemberName(), closed, items); + } + + // ── Member submits votes ─────────────────────────────────────────────────── + + @Transactional + public void submitVotes(String token, MeetingVoteSubmitRequest request) { + MeetingVoteToken voteToken = tokenRepository.findByTokenAndUsedFalse(token) + .orElseThrow(() -> new IllegalStateException("Voting link is invalid or already used")); + + if (voteToken.isExpired()) { + throw new IllegalStateException("Voting link has expired"); + } + + if (request.getVotes() == null || request.getVotes().isEmpty()) { + throw new IllegalArgumentException("No votes provided"); + } + + Long meetingId = voteToken.getMeetingId(); + Long memberId = voteToken.getMemberId(); + + // Delete any previous votes for this member on this meeting (allow re-vote before marking used) + voteRepository.deleteByMeetingIdAndMemberId(meetingId, memberId); + voteRepository.flush(); + + List toSave = new ArrayList<>(); + for (AgendaVoteRequest v : request.getVotes()) { + if (v.getAgendaId() == null || v.getVote() == null) continue; + try { + AgendaVote agendaVote = AgendaVote.valueOf(v.getVote().toUpperCase()); + toSave.add(MeetingMemberVote.of(meetingId, v.getAgendaId(), memberId, agendaVote, v.getRemark())); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid vote value: " + v.getVote() + ". Use APPROVED, REJECTED or ABSTAIN"); + } + } + + voteRepository.saveAll(toSave); + + voteToken.setUsed(true); + tokenRepository.save(voteToken); + } + + // ── Admin: vote summary per agenda item ─────────────────────────────────── + + @Transactional(readOnly = true) + public List getVoteSummary(Long meetingId) { + CommitteeMeeting meeting = meetingRepository.findByIdAndIsDeletedFalse(meetingId) + .orElseThrow(() -> new IllegalArgumentException("Meeting not found")); + + long totalMembers = memberRepository.findByCommitteeId(meeting.getCommitteeId()).size(); + + List allVotes = voteRepository.findByMeetingId(meetingId); + List resolutions = resolutionRepository.findByMeetingId(meetingId); + + Map resolutionMap = resolutions.stream() + .collect(Collectors.toMap(MeetingAgendaResolution::getAgendaId, r -> r)); + + return meeting.getAgendaItems().stream().map(agenda -> { + List itemVotes = allVotes.stream() + .filter(v -> v.getAgendaId().equals(agenda.getId())).toList(); + + long approved = itemVotes.stream().filter(v -> v.getVote() == AgendaVote.APPROVED).count(); + long rejected = itemVotes.stream().filter(v -> v.getVote() == AgendaVote.REJECTED).count(); + long abstain = itemVotes.stream().filter(v -> v.getVote() == AgendaVote.ABSTAIN).count(); + long pending = totalMembers - itemVotes.size(); + + MeetingAgendaResolution res = resolutionMap.get(agenda.getId()); + return new AgendaVoteSummaryData( + agenda.getId(), agenda.getAgendaItem(), agenda.getDisplayOrder(), + approved, rejected, abstain, Math.max(pending, 0), totalMembers, + res != null ? res.getResolution().name() : null, + res != null ? res.getDiscussionNotes() : null); + }).toList(); + } + + // ── Admin: save final resolutions ───────────────────────────────────────── + + @Transactional + public void saveResolutions(Long meetingId, List requests) { + CommitteeMeeting meeting = meetingRepository.findByIdAndIsDeletedFalse(meetingId) + .orElseThrow(() -> new IllegalArgumentException("Meeting not found")); + + resolutionRepository.deleteByMeetingId(meetingId); + resolutionRepository.flush(); + + List toSave = new ArrayList<>(); + for (AgendaResolutionRequest req : requests) { + if (req.getAgendaId() == null || req.getResolution() == null) continue; + try { + AgendaResolution res = AgendaResolution.valueOf(req.getResolution().toUpperCase()); + toSave.add(MeetingAgendaResolution.of(meetingId, req.getAgendaId(), res, req.getDiscussionNotes())); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid resolution: " + req.getResolution()); + } + } + resolutionRepository.saveAll(toSave); + + sendMinutesEmail(meeting); + } + + // ── Voting email (called when meeting marked COMPLETED) ─────────────────── + + @Transactional + public void sendVotingEmails(Long meetingId) { + CommitteeMeeting meeting = meetingRepository.findByIdAndIsDeletedFalse(meetingId) + .orElseThrow(() -> new IllegalArgumentException("Meeting not found")); + + if (meeting.getAgendaItems().isEmpty()) return; + + String committeeName = committeeRepository.findById(meeting.getCommitteeId()) + .map(Committee::getName).orElse(""); + + // Invalidate old tokens for this meeting before generating new ones + tokenRepository.deleteByMeetingId(meetingId); + tokenRepository.flush(); + + List members = memberRepository.findByCommitteeId(meeting.getCommitteeId()); + + for (CommitteeMember member : members) { + try { + DirectorAppointment appointment = directorAppointmentRepository + .findById(member.getDirectorAppointmentId()).orElse(null); + if (appointment == null) continue; + + ElectionCandidate candidate = electionCandidateRepository + .findById(appointment.getCandidateId()).orElse(null); + if (candidate == null || candidate.getNomination() == null) continue; + + Long clientId = candidate.getNomination().getMemberId(); + if (clientId == null) continue; + + String email = clientRepository.findById(clientId) + .map(c -> c.emailAddress()) + .filter(e -> e != null && !e.isBlank()) + .orElse(null); + if (email == null) continue; + + MeetingVoteToken token = MeetingVoteToken.generate( + meetingId, member.getId(), appointment.getCandidateName(), 7); + tokenRepository.save(token); + + String html = buildVotingEmailHtml( + appointment.getCandidateName(), committeeName, meeting, token.getToken()); + String subject = "🗳️ Vote Required — " + meeting.getTitle(); + boolean sent = emailSenderService.sendHtml(email, subject, html); + if (!sent) log.warn("Failed to send voting email to {}", email); + + } catch (Exception e) { + log.warn("Error sending voting email to member {}: {}", member.getId(), e.getMessage()); + } + } + } + + // ── Email builders ──────────────────────────────────────────────────────── + + private String buildVotingEmailHtml(String name, String committeeName, + CommitteeMeeting meeting, String token) { + String baseUrl = frontendBaseUrl + "/#/vote/" + token; + String dateStr = meeting.getMeetingDate() != null + ? meeting.getMeetingDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")) : "—"; + String sentAt = java.time.LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("dd MMM yyyy, hh:mm a")); + + StringBuilder sb = new StringBuilder(); + sb.append("") + .append("") + .append("") + .append("
") + .append("") + // Header + .append("") + // Body + .append("") + // Agenda items with vote buttons + .append("") + .append("") + // Footer + .append("") + .append("
") + .append("
CBSnexora · Committee Meeting
") + .append("
🗳️ Vote Required
") + .append("
👥 ").append(committeeName).append("
") + .append("
") + .append("

Dear ").append(name).append(",

") + .append("

The meeting \"").append(meeting.getTitle()) + .append("\" held on ").append(dateStr) + .append(" has concluded. Please review the agenda and cast your vote below.

") + .append("
") + .append("
"); + + List items = meeting.getAgendaItems(); + for (int i = 0; i < items.size(); i++) { + CommitteeMeetingAgenda item = items.get(i); + String border = i < items.size() - 1 ? "border-bottom:1px solid #e2e8f0;" : ""; + String approveUrl = baseUrl + "?single=" + item.getId() + "&v=APPROVED"; + String rejectUrl = baseUrl + "?single=" + item.getId() + "&v=REJECTED"; + sb.append("
") + .append("
") + .append(i + 1).append(". ").append(item.getAgendaItem()) + .append("
") + .append("
"); + // Buttons won't work as links in all clients — we link the entire page + sb.append("
"); + } + + // Single "Vote Now" button linking to the full voting page + sb.append("
") + .append("") + .append("🗳️  Cast Your Vote") + .append("

Link valid for 7 days. Each member can vote once.

") + .append("
") + .append("") + .append("") + .append("") + .append("
Automated notification from CBSnexoraSent: ").append(sentAt).append("
"); + + return sb.toString(); + } + + private void sendMinutesEmail(CommitteeMeeting meeting) { + List members = memberRepository.findByCommitteeId(meeting.getCommitteeId()); + if (members.isEmpty()) return; + + String committeeName = committeeRepository.findById(meeting.getCommitteeId()) + .map(Committee::getName).orElse(""); + + List resolutions = resolutionRepository.findByMeetingId(meeting.getId()); + Map resMap = resolutions.stream() + .collect(Collectors.toMap(MeetingAgendaResolution::getAgendaId, r -> r)); + + String subject = "📋 Meeting Minutes — " + meeting.getTitle(); + + for (CommitteeMember member : members) { + try { + DirectorAppointment appointment = directorAppointmentRepository + .findById(member.getDirectorAppointmentId()).orElse(null); + if (appointment == null) continue; + + ElectionCandidate candidate = electionCandidateRepository + .findById(appointment.getCandidateId()).orElse(null); + if (candidate == null || candidate.getNomination() == null) continue; + + Long clientId = candidate.getNomination().getMemberId(); + if (clientId == null) continue; + + String email = clientRepository.findById(clientId) + .map(c -> c.emailAddress()) + .filter(e -> e != null && !e.isBlank()) + .orElse(null); + if (email == null) continue; + + String html = buildMinutesEmailHtml( + appointment.getCandidateName(), committeeName, meeting, resMap); + boolean sent = emailSenderService.sendHtml(email, subject, html); + if (!sent) log.warn("Failed to send minutes email to {}", email); + + } catch (Exception e) { + log.warn("Error sending minutes email to member {}: {}", member.getId(), e.getMessage()); + } + } + } + + private String buildMinutesEmailHtml(String name, String committeeName, + CommitteeMeeting meeting, + Map resMap) { + String dateStr = meeting.getMeetingDate() != null + ? meeting.getMeetingDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")) : "—"; + String sentAt = java.time.LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("dd MMM yyyy, hh:mm a")); + + StringBuilder sb = new StringBuilder(); + sb.append("") + .append("") + .append("") + .append("
") + .append("") + // Header + .append("") + // Greeting + .append("") + // Agenda + resolutions + .append("") + // Footer + .append("") + .append("
") + .append("
CBSnexora · Meeting Minutes
") + .append("
📋 ").append(meeting.getTitle()).append("
") + .append("
👥 ").append(committeeName) + .append("  ·  ").append(dateStr).append("
") + .append("
") + .append("

Dear ").append(name).append(",

") + .append("

") + .append("Please find below the official minutes and resolutions from the committee meeting.

") + .append("
"); + + List items = meeting.getAgendaItems(); + for (int i = 0; i < items.size(); i++) { + CommitteeMeetingAgenda item = items.get(i); + MeetingAgendaResolution res = resMap.get(item.getId()); + String resLabel = "PENDING"; + String resColor = "#64748b"; + String resBg = "#f1f5f9"; + if (res != null) { + switch (res.getResolution()) { + case APPROVED -> { resLabel = "✅ APPROVED"; resColor = "#16a34a"; resBg = "#dcfce7"; } + case REJECTED -> { resLabel = "❌ REJECTED"; resColor = "#dc2626"; resBg = "#fee2e2"; } + case DEFERRED -> { resLabel = "⏸ DEFERRED"; resColor = "#d97706"; resBg = "#fef3c7"; } + } + } + sb.append("
") + .append("
") + .append("
") + .append(i + 1).append(". ").append(item.getAgendaItem()).append("
") + .append("") + .append(resLabel).append("
"); + if (res != null && res.getDiscussionNotes() != null && !res.getDiscussionNotes().isBlank()) { + sb.append("
") + .append("Discussion: ").append(res.getDiscussionNotes()).append("
"); + } + sb.append("
"); + } + + sb.append("
") + .append("") + .append("") + .append("") + .append("
Automated notification from CBSnexoraSent: ").append(sentAt).append("
"); + + return sb.toString(); + } +} diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 3432b0ff3..7f1c08b22 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -687,3 +687,6 @@ spring.mail.properties.mail.transport.protocol=smtp fineract.email.from-address=${FINERACT_EMAIL_FROM_ADDRESS:no-reply@cbsnexora.com} fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora} fineract.email.support-address=${FINERACT_EMAIL_SUPPORT_ADDRESS:support@cbsnexora.com} + +# Frontend base URL — used for email links (voting, etc.) +cbs.frontend.base-url=${CBS_FRONTEND_BASE_URL:http://localhost:4200} 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 91947cb9b..573edc55f 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 @@ -381,4 +381,7 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0354_meeting_vote_resolution.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0354_meeting_vote_resolution.xml new file mode 100644 index 000000000..0c819a670 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0354_meeting_vote_resolution.xml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0355_director_designation_code.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0355_director_designation_code.xml new file mode 100644 index 000000000..354d5f6b0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0355_director_designation_code.xml @@ -0,0 +1,35 @@ + + + + + + INSERT INTO m_code (code_name, is_system_defined) + SELECT 'DirectorDesignation', false + WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'DirectorDesignation'); + + + + + + INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory, code_score) + SELECT c.id, v.code_value, v.order_position, true, v.is_mandatory, v.code_score + FROM m_code c + CROSS JOIN (VALUES + ('Chairman', 1, true, 1), + ('Vice Chairman', 2, true, 1), + ('Secretary', 3, false, NULL), + ('Treasurer', 4, false, NULL), + ('Director', 5, false, NULL), + ('Principal Director', 6, false, 2) + ) AS v(code_value, order_position, is_mandatory, code_score) + WHERE c.code_name = 'DirectorDesignation' + AND NOT EXISTS ( + SELECT 1 FROM m_code_value cv + WHERE cv.code_id = c.id AND cv.code_value = v.code_value + ); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0356_meeting_vote_remark.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0356_meeting_vote_remark.xml new file mode 100644 index 000000000..90b2d3d18 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0356_meeting_vote_remark.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + +