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()
|
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/*/echo")).permitAll()
|
||||||
.requestMatchers(API_MATCHER.matcher(HttpMethod.POST, "/api/*/authentication")).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 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
|
// 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
|
// 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 lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.portfolio.election.data.DirectorAppointmentRequest;
|
import org.apache.fineract.portfolio.election.data.DirectorAppointmentRequest;
|
||||||
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
|
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.DirectorAppointmentService;
|
||||||
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
|
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -27,6 +28,14 @@ public class DirectorAppointmentApiResource {
|
|||||||
|
|
||||||
private final DirectorAppointmentService service;
|
private final DirectorAppointmentService service;
|
||||||
private final ElectionAuditService auditService;
|
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
|
@GET
|
||||||
@Path("/pending")
|
@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);
|
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
|
@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")
|
@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);
|
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 org.apache.fineract.portfolio.election.domain.ElectionCandidateRepository;
|
||||||
import jakarta.persistence.EntityManager;
|
import jakarta.persistence.EntityManager;
|
||||||
import jakarta.persistence.PersistenceContext;
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
import org.apache.fineract.portfolio.election.service.MeetingVoteService;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
|
|||||||
private final ElectionCandidateRepository electionCandidateRepository;
|
private final ElectionCandidateRepository electionCandidateRepository;
|
||||||
private final ClientRepository clientRepository;
|
private final ClientRepository clientRepository;
|
||||||
private final EmailSenderService emailSenderService;
|
private final EmailSenderService emailSenderService;
|
||||||
|
private final MeetingVoteService meetingVoteService;
|
||||||
|
|
||||||
@PersistenceContext
|
@PersistenceContext
|
||||||
private EntityManager entityManager;
|
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");
|
throw new IllegalStateException("Meeting is already " + meeting.getStatus().name() + " and cannot be changed");
|
||||||
}
|
}
|
||||||
meeting.setStatus(newStatus);
|
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
|
@Override
|
||||||
|
|||||||
+29
@@ -37,10 +37,13 @@ public class CommitteeServiceImpl implements CommitteeService {
|
|||||||
private final ElectionCandidateRepository candidateRepository;
|
private final ElectionCandidateRepository candidateRepository;
|
||||||
private final ElectionConfigRepository electionConfigRepository;
|
private final ElectionConfigRepository electionConfigRepository;
|
||||||
private final ElectionAuditService auditService;
|
private final ElectionAuditService auditService;
|
||||||
|
private final DesignationService designationService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public CommitteeData createCommittee(CommitteeRequest request) {
|
public CommitteeData createCommittee(CommitteeRequest request) {
|
||||||
|
validateRequiredDesignationsPresent(request.getDirectorAppointmentIds());
|
||||||
|
|
||||||
Committee committee = Committee.create(request.getName(), request.getDescription());
|
Committee committee = Committee.create(request.getName(), request.getDescription());
|
||||||
committee.setImage(request.getImage());
|
committee.setImage(request.getImage());
|
||||||
committeeRepository.save(committee);
|
committeeRepository.save(committee);
|
||||||
@@ -226,4 +229,30 @@ public class CommitteeServiceImpl implements CommitteeService {
|
|||||||
.members(members)
|
.members(members)
|
||||||
.build();
|
.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 ElectionConfigRepository electionConfigRepository;
|
||||||
private final ElectionCandidateRepository candidateRepository;
|
private final ElectionCandidateRepository candidateRepository;
|
||||||
private final ElectionAuditService auditService;
|
private final ElectionAuditService auditService;
|
||||||
|
private final DesignationService designationService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public Long appoint(DirectorAppointmentRequest req) {
|
public Long appoint(DirectorAppointmentRequest req) {
|
||||||
overrideTenureEndFromElection(req);
|
overrideTenureEndFromElection(req);
|
||||||
validateTenureDates(req);
|
validateTenureDates(req);
|
||||||
|
validateDesignationLimit(req.getDesignation(), null);
|
||||||
if (repository.existsByElectionIdAndCandidateId(req.getElectionId(), req.getCandidateId())) {
|
if (repository.existsByElectionIdAndCandidateId(req.getElectionId(), req.getCandidateId())) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"Candidate " + req.getCandidateId() + " is already appointed for election " + req.getElectionId());
|
"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());
|
if (req.getElectionId() == null) req.setElectionId(existing.getElectionId());
|
||||||
overrideTenureEndFromElection(req);
|
overrideTenureEndFromElection(req);
|
||||||
validateTenureDates(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())
|
boolean isRemoval = "REMOVED".equalsIgnoreCase(req.getStatus())
|
||||||
&& existing.getStatus() != DirectorAppointmentStatus.REMOVED;
|
&& existing.getStatus() != DirectorAppointmentStatus.REMOVED;
|
||||||
applyFields(existing, req);
|
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) {
|
private DirectorAppointment findOrThrow(Long id) {
|
||||||
return repository.findById(id).orElseThrow(
|
return repository.findById(id).orElseThrow(
|
||||||
() -> new AbstractPlatformResourceNotFoundException("error.msg.director.appointment.not.found",
|
() -> 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-address=${FINERACT_EMAIL_FROM_ADDRESS:no-reply@cbsnexora.com}
|
||||||
fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora}
|
fineract.email.from-name=${FINERACT_EMAIL_FROM_NAME:CBSnexora}
|
||||||
fineract.email.support-address=${FINERACT_EMAIL_SUPPORT_ADDRESS:support@cbsnexora.com}
|
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/0351_committee_meeting.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0352_committee_meeting_rename_audit_cols.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/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>
|
</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