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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
cfcd24efad
commit
934a51c9ee
+3
@@ -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
|
||||
|
||||
+9
@@ -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")
|
||||
|
||||
+53
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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<AgendaResolutionRequest> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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;
|
||||
}
|
||||
+10
@@ -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)
|
||||
}
|
||||
+19
@@ -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;
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package org.apache.fineract.portfolio.election.data;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class MeetingVoteSubmitRequest {
|
||||
private List<AgendaVoteRequest> votes;
|
||||
}
|
||||
+27
@@ -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<AgendaItemVoteData> agendaItems;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public static class AgendaItemVoteData {
|
||||
private Long agendaId;
|
||||
private String agendaItem;
|
||||
private int displayOrder;
|
||||
private String currentVote; // null if not yet voted
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package org.apache.fineract.portfolio.election.domain;
|
||||
|
||||
public enum AgendaResolution {
|
||||
APPROVED, REJECTED, DEFERRED
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package org.apache.fineract.portfolio.election.domain;
|
||||
|
||||
public enum AgendaVote {
|
||||
APPROVED, REJECTED, ABSTAIN
|
||||
}
|
||||
+7
@@ -28,6 +28,13 @@ public interface DirectorAppointmentRepository extends JpaRepository<DirectorApp
|
||||
|
||||
boolean existsByElectionIdAndCandidateId(Long electionId, Long candidateId);
|
||||
|
||||
int countByDesignationAndStatus(String designation, DirectorAppointmentStatus status);
|
||||
|
||||
@Query("SELECT COUNT(d) FROM DirectorAppointment d WHERE d.designation = :designation AND d.status = :status AND d.id != :excludeId")
|
||||
int countByDesignationAndStatusExcluding(@Param("designation") String designation,
|
||||
@Param("status") DirectorAppointmentStatus status,
|
||||
@Param("excludeId") Long excludeId);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE DirectorAppointment d SET d.status = org.apache.fineract.portfolio.election.domain.DirectorAppointmentStatus.TENURE_ENDED WHERE d.status = org.apache.fineract.portfolio.election.domain.DirectorAppointmentStatus.ACTIVE AND d.tenureEndDate < :today")
|
||||
int expireByTenureDate(@Param("today") LocalDate today);
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
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_agenda_resolution")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class MeetingAgendaResolution {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "meeting_id", nullable = false)
|
||||
private Long meetingId;
|
||||
|
||||
@Column(name = "agenda_id", nullable = false, unique = true)
|
||||
private Long agendaId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "resolution", nullable = false, length = 20)
|
||||
private AgendaResolution resolution;
|
||||
|
||||
@Column(name = "discussion_notes", columnDefinition = "TEXT")
|
||||
private String discussionNotes;
|
||||
|
||||
@Column(name = "resolved_at", nullable = false)
|
||||
private LocalDateTime resolvedAt;
|
||||
|
||||
public static MeetingAgendaResolution of(Long meetingId, Long agendaId,
|
||||
AgendaResolution resolution, String notes) {
|
||||
MeetingAgendaResolution r = new MeetingAgendaResolution();
|
||||
r.meetingId = meetingId;
|
||||
r.agendaId = agendaId;
|
||||
r.resolution = resolution;
|
||||
r.discussionNotes = notes;
|
||||
r.resolvedAt = LocalDateTime.now();
|
||||
return r;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
|
||||
@Repository
|
||||
public interface MeetingAgendaResolutionRepository extends JpaRepository<MeetingAgendaResolution, Long> {
|
||||
|
||||
List<MeetingAgendaResolution> findByMeetingId(Long meetingId);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM MeetingAgendaResolution r WHERE r.meetingId = :meetingId")
|
||||
void deleteByMeetingId(@Param("meetingId") Long meetingId);
|
||||
}
|
||||
+53
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
@@ -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<MeetingMemberVote, Long> {
|
||||
|
||||
List<MeetingMemberVote> findByMeetingId(Long meetingId);
|
||||
|
||||
List<MeetingMemberVote> findByMeetingIdAndMemberId(Long meetingId, Long memberId);
|
||||
|
||||
Optional<MeetingMemberVote> 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);
|
||||
}
|
||||
+55
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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<MeetingVoteToken, String> {
|
||||
|
||||
Optional<MeetingVoteToken> findByTokenAndUsedFalse(String token);
|
||||
|
||||
List<MeetingVoteToken> findByMeetingId(Long meetingId);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM MeetingVoteToken t WHERE t.meetingId = :meetingId")
|
||||
void deleteByMeetingId(@Param("meetingId") Long meetingId);
|
||||
}
|
||||
+11
-1
@@ -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
|
||||
|
||||
+29
@@ -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<Long> directorAppointmentIds) {
|
||||
Set<String> 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<DirectorAppointment> directors = directorRepository.findAllById(directorAppointmentIds);
|
||||
Set<String> present = directors.stream()
|
||||
.filter(d -> d.getStatus() == DirectorAppointmentStatus.ACTIVE)
|
||||
.map(DirectorAppointment::getDesignation)
|
||||
.collect(Collectors.toSet());
|
||||
Set<String> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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<DesignationData> retrieveAll();
|
||||
/** designation name → max active count (only entries with a configured limit) */
|
||||
Map<String, Integer> getDesignationLimits();
|
||||
/** designations that must be present when creating a new committee */
|
||||
Set<String> getMandatoryDesignations();
|
||||
}
|
||||
+60
@@ -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<DesignationData> 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<String, Integer> getDesignationLimits() {
|
||||
Map<String, Integer> limits = new HashMap<>();
|
||||
for (DesignationData d : retrieveAll()) {
|
||||
if (d.getLimitCount() != null) {
|
||||
limits.put(d.getName(), d.getLimitCount());
|
||||
}
|
||||
}
|
||||
return limits;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getMandatoryDesignations() {
|
||||
Set<String> mandatory = new HashSet<>();
|
||||
for (DesignationData d : retrieveAll()) {
|
||||
if (d.isMandatory()) {
|
||||
mandatory.add(d.getName());
|
||||
}
|
||||
}
|
||||
return mandatory;
|
||||
}
|
||||
}
|
||||
+26
@@ -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<String, Integer> 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",
|
||||
|
||||
+400
@@ -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<Long, String> existingVotes = voteRepository
|
||||
.findByMeetingIdAndMemberId(meeting.getId(), voteToken.getMemberId())
|
||||
.stream().collect(Collectors.toMap(
|
||||
MeetingMemberVote::getAgendaId,
|
||||
v -> v.getVote().name()));
|
||||
|
||||
List<MeetingVotingPageData.AgendaItemVoteData> 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<MeetingMemberVote> 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<AgendaVoteSummaryData> getVoteSummary(Long meetingId) {
|
||||
CommitteeMeeting meeting = meetingRepository.findByIdAndIsDeletedFalse(meetingId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Meeting not found"));
|
||||
|
||||
long totalMembers = memberRepository.findByCommitteeId(meeting.getCommitteeId()).size();
|
||||
|
||||
List<MeetingMemberVote> allVotes = voteRepository.findByMeetingId(meetingId);
|
||||
List<MeetingAgendaResolution> resolutions = resolutionRepository.findByMeetingId(meetingId);
|
||||
|
||||
Map<Long, MeetingAgendaResolution> resolutionMap = resolutions.stream()
|
||||
.collect(Collectors.toMap(MeetingAgendaResolution::getAgendaId, r -> r));
|
||||
|
||||
return meeting.getAgendaItems().stream().map(agenda -> {
|
||||
List<MeetingMemberVote> 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<AgendaResolutionRequest> requests) {
|
||||
CommitteeMeeting meeting = meetingRepository.findByIdAndIsDeletedFalse(meetingId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Meeting not found"));
|
||||
|
||||
resolutionRepository.deleteByMeetingId(meetingId);
|
||||
resolutionRepository.flush();
|
||||
|
||||
List<MeetingAgendaResolution> 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<CommitteeMember> 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("<!DOCTYPE html><html><head><meta charset='UTF-8'></head>")
|
||||
.append("<body style='margin:0;padding:0;background:#f0f4ff;font-family:Arial,Helvetica,sans-serif;'>")
|
||||
.append("<table width='100%' cellpadding='0' cellspacing='0' style='background:#f0f4ff;padding:24px 0;'>")
|
||||
.append("<tr><td align='center'>")
|
||||
.append("<table width='560' cellpadding='0' cellspacing='0' style='background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.10);max-width:560px;'>")
|
||||
// Header
|
||||
.append("<tr><td style='background:#7c3aed;padding:22px 28px;'>")
|
||||
.append("<div style='font-size:11px;font-weight:700;color:rgba(255,255,255,.6);letter-spacing:1.5px;text-transform:uppercase;margin-bottom:4px;'>CBSnexora · Committee Meeting</div>")
|
||||
.append("<div style='font-size:20px;font-weight:800;color:#fff;'>🗳️ Vote Required</div>")
|
||||
.append("<div style='margin-top:6px;font-size:13px;color:rgba(255,255,255,.75);'>👥 ").append(committeeName).append("</div>")
|
||||
.append("</td></tr>")
|
||||
// Body
|
||||
.append("<tr><td style='padding:24px 28px 16px;'>")
|
||||
.append("<p style='margin:0 0 4px;font-size:15px;color:#0f172a;'>Dear <strong>").append(name).append("</strong>,</p>")
|
||||
.append("<p style='margin:8px 0 0;font-size:13px;color:#64748b;'>The meeting <strong>\"").append(meeting.getTitle())
|
||||
.append("\"</strong> held on ").append(dateStr)
|
||||
.append(" has concluded. Please review the agenda and cast your vote below.</p>")
|
||||
.append("</td></tr>")
|
||||
// Agenda items with vote buttons
|
||||
.append("<tr><td style='padding:0 28px 20px;'>")
|
||||
.append("<div style='background:#f8faff;border:1px solid #e2e8f0;border-radius:10px;overflow:hidden;'>");
|
||||
|
||||
List<CommitteeMeetingAgenda> 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("<div style='padding:14px 16px;").append(border).append("'>")
|
||||
.append("<div style='font-size:13px;font-weight:600;color:#1e293b;margin-bottom:8px;'>")
|
||||
.append(i + 1).append(". ").append(item.getAgendaItem())
|
||||
.append("</div>")
|
||||
.append("<div>");
|
||||
// Buttons won't work as links in all clients — we link the entire page
|
||||
sb.append("</div></div>");
|
||||
}
|
||||
|
||||
// Single "Vote Now" button linking to the full voting page
|
||||
sb.append("</div></td></tr>")
|
||||
.append("<tr><td style='padding:0 28px 24px;text-align:center;'>")
|
||||
.append("<a href='").append(baseUrl).append("' style='display:inline-block;background:#7c3aed;color:#fff;")
|
||||
.append("font-size:15px;font-weight:700;padding:14px 40px;border-radius:8px;text-decoration:none;'>")
|
||||
.append("🗳️ Cast Your Vote</a>")
|
||||
.append("<p style='font-size:12px;color:#94a3b8;margin:12px 0 0;'>Link valid for 7 days. Each member can vote once.</p>")
|
||||
.append("</td></tr>")
|
||||
// Footer
|
||||
.append("<tr><td style='background:#f8fafc;border-top:1px solid #e2e8f0;padding:12px 28px;'>")
|
||||
.append("<table width='100%' cellpadding='0' cellspacing='0'><tr>")
|
||||
.append("<td style='font-size:11px;color:#94a3b8;'>Automated notification from <strong>CBSnexora</strong></td>")
|
||||
.append("<td align='right' style='font-size:11px;color:#94a3b8;white-space:nowrap;'>Sent: ").append(sentAt).append("</td>")
|
||||
.append("</tr></table></td></tr>")
|
||||
.append("</table></td></tr></table></body></html>");
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private void sendMinutesEmail(CommitteeMeeting meeting) {
|
||||
List<CommitteeMember> members = memberRepository.findByCommitteeId(meeting.getCommitteeId());
|
||||
if (members.isEmpty()) return;
|
||||
|
||||
String committeeName = committeeRepository.findById(meeting.getCommitteeId())
|
||||
.map(Committee::getName).orElse("");
|
||||
|
||||
List<MeetingAgendaResolution> resolutions = resolutionRepository.findByMeetingId(meeting.getId());
|
||||
Map<Long, MeetingAgendaResolution> 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<Long, MeetingAgendaResolution> 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("<!DOCTYPE html><html><head><meta charset='UTF-8'></head>")
|
||||
.append("<body style='margin:0;padding:0;background:#f0f4ff;font-family:Arial,Helvetica,sans-serif;'>")
|
||||
.append("<table width='100%' cellpadding='0' cellspacing='0' style='background:#f0f4ff;padding:24px 0;'>")
|
||||
.append("<tr><td align='center'>")
|
||||
.append("<table width='560' cellpadding='0' cellspacing='0' style='background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,.10);max-width:560px;'>")
|
||||
// Header
|
||||
.append("<tr><td style='background:#0f766e;padding:22px 28px;'>")
|
||||
.append("<div style='font-size:11px;font-weight:700;color:rgba(255,255,255,.6);letter-spacing:1.5px;text-transform:uppercase;margin-bottom:4px;'>CBSnexora · Meeting Minutes</div>")
|
||||
.append("<div style='font-size:20px;font-weight:800;color:#fff;'>📋 ").append(meeting.getTitle()).append("</div>")
|
||||
.append("<div style='margin-top:6px;font-size:13px;color:rgba(255,255,255,.75);'>👥 ").append(committeeName)
|
||||
.append(" · ").append(dateStr).append("</div>")
|
||||
.append("</td></tr>")
|
||||
// Greeting
|
||||
.append("<tr><td style='padding:24px 28px 16px;'>")
|
||||
.append("<p style='margin:0;font-size:15px;color:#0f172a;'>Dear <strong>").append(name).append("</strong>,</p>")
|
||||
.append("<p style='margin:8px 0 0;font-size:13px;color:#64748b;'>")
|
||||
.append("Please find below the official minutes and resolutions from the committee meeting.</p>")
|
||||
.append("</td></tr>")
|
||||
// Agenda + resolutions
|
||||
.append("<tr><td style='padding:0 28px 20px;'>");
|
||||
|
||||
List<CommitteeMeetingAgenda> 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("<div style='background:#f8faff;border:1px solid #e2e8f0;border-radius:8px;padding:14px 16px;margin-bottom:10px;'>")
|
||||
.append("<div style='display:flex;justify-content:space-between;align-items:flex-start;'>")
|
||||
.append("<div style='font-size:13px;font-weight:600;color:#1e293b;flex:1;'>")
|
||||
.append(i + 1).append(". ").append(item.getAgendaItem()).append("</div>")
|
||||
.append("<span style='background:").append(resBg).append(";color:").append(resColor)
|
||||
.append(";padding:3px 10px;border-radius:20px;font-size:11px;font-weight:700;white-space:nowrap;margin-left:12px;'>")
|
||||
.append(resLabel).append("</span></div>");
|
||||
if (res != null && res.getDiscussionNotes() != null && !res.getDiscussionNotes().isBlank()) {
|
||||
sb.append("<div style='margin-top:8px;font-size:12px;color:#475569;font-style:italic;'>")
|
||||
.append("Discussion: ").append(res.getDiscussionNotes()).append("</div>");
|
||||
}
|
||||
sb.append("</div>");
|
||||
}
|
||||
|
||||
sb.append("</td></tr>")
|
||||
// Footer
|
||||
.append("<tr><td style='background:#f8fafc;border-top:1px solid #e2e8f0;padding:12px 28px;'>")
|
||||
.append("<table width='100%' cellpadding='0' cellspacing='0'><tr>")
|
||||
.append("<td style='font-size:11px;color:#94a3b8;'>Automated notification from <strong>CBSnexora</strong></td>")
|
||||
.append("<td align='right' style='font-size:11px;color:#94a3b8;white-space:nowrap;'>Sent: ").append(sentAt).append("</td>")
|
||||
.append("</tr></table></td></tr>")
|
||||
.append("</table></td></tr></table></body></html>");
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -381,4 +381,7 @@
|
||||
<include file="parts/0351_committee_meeting.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0352_committee_meeting_rename_audit_cols.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0353_committee_meeting_agenda.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0354_meeting_vote_resolution.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0355_director_designation_code.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0356_meeting_vote_remark.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
<?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">
|
||||
|
||||
<!-- Vote token: one per member per meeting, carries the secure email link -->
|
||||
<changeSet id="0354-meeting-vote-token" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_election_meeting_vote_token"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_election_meeting_vote_token">
|
||||
<column name="token" type="VARCHAR(36)">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="meeting_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="member_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="member_name" type="VARCHAR(200)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="is_used" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="expires_at" type="TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_at" type="TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
<addForeignKeyConstraint
|
||||
baseTableName="m_election_meeting_vote_token"
|
||||
baseColumnNames="meeting_id"
|
||||
referencedTableName="m_election_committee_meeting"
|
||||
referencedColumnNames="id"
|
||||
constraintName="fk_vote_token_meeting"
|
||||
onDelete="CASCADE"/>
|
||||
<createIndex tableName="m_election_meeting_vote_token" indexName="idx_vote_token_meeting_member">
|
||||
<column name="meeting_id"/>
|
||||
<column name="member_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<!-- Member vote: one row per member per agenda item -->
|
||||
<changeSet id="0354-meeting-member-vote" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_election_meeting_member_vote"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_election_meeting_member_vote">
|
||||
<column name="id" type="BIGSERIAL">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="meeting_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="agenda_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="member_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="vote" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="voted_at" type="TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
<addForeignKeyConstraint
|
||||
baseTableName="m_election_meeting_member_vote"
|
||||
baseColumnNames="meeting_id"
|
||||
referencedTableName="m_election_committee_meeting"
|
||||
referencedColumnNames="id"
|
||||
constraintName="fk_member_vote_meeting"
|
||||
onDelete="CASCADE"/>
|
||||
<addForeignKeyConstraint
|
||||
baseTableName="m_election_meeting_member_vote"
|
||||
baseColumnNames="agenda_id"
|
||||
referencedTableName="m_election_committee_meeting_agenda"
|
||||
referencedColumnNames="id"
|
||||
constraintName="fk_member_vote_agenda"
|
||||
onDelete="CASCADE"/>
|
||||
<addUniqueConstraint
|
||||
tableName="m_election_meeting_member_vote"
|
||||
columnNames="agenda_id, member_id"
|
||||
constraintName="uq_member_vote_agenda_member"/>
|
||||
<createIndex tableName="m_election_meeting_member_vote" indexName="idx_member_vote_meeting">
|
||||
<column name="meeting_id"/>
|
||||
<column name="member_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<!-- Agenda resolution: admin's final recorded decision per agenda item -->
|
||||
<changeSet id="0354-meeting-agenda-resolution" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_election_meeting_agenda_resolution"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_election_meeting_agenda_resolution">
|
||||
<column name="id" type="BIGSERIAL">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="meeting_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="agenda_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="resolution" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="discussion_notes" type="TEXT"/>
|
||||
<column name="resolved_at" type="TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
<addForeignKeyConstraint
|
||||
baseTableName="m_election_meeting_agenda_resolution"
|
||||
baseColumnNames="meeting_id"
|
||||
referencedTableName="m_election_committee_meeting"
|
||||
referencedColumnNames="id"
|
||||
constraintName="fk_resolution_meeting"
|
||||
onDelete="CASCADE"/>
|
||||
<addForeignKeyConstraint
|
||||
baseTableName="m_election_meeting_agenda_resolution"
|
||||
baseColumnNames="agenda_id"
|
||||
referencedTableName="m_election_committee_meeting_agenda"
|
||||
referencedColumnNames="id"
|
||||
constraintName="fk_resolution_agenda"
|
||||
onDelete="CASCADE"/>
|
||||
<addUniqueConstraint
|
||||
tableName="m_election_meeting_agenda_resolution"
|
||||
columnNames="agenda_id"
|
||||
constraintName="uq_resolution_agenda"/>
|
||||
<createIndex tableName="m_election_meeting_agenda_resolution" indexName="idx_resolution_meeting">
|
||||
<column name="meeting_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?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="0355-director-designation-code" author="CBS">
|
||||
<sql>
|
||||
INSERT INTO m_code (code_name, is_system_defined)
|
||||
SELECT 'DirectorDesignation', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'DirectorDesignation');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0355-director-designation-values" author="CBS">
|
||||
<sql>
|
||||
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
|
||||
);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<?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="0356-meeting-vote-remark" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_election_meeting_member_vote" columnName="remark"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_election_meeting_member_vote">
|
||||
<column name="remark" type="TEXT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user