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:
Jaanu423
2026-07-07 18:26:14 +05:30
co-authored by Claude Sonnet 4.6
parent cfcd24efad
commit 934a51c9ee
30 changed files with 1196 additions and 1 deletions
@@ -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
@@ -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")
@@ -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();
}
}
}
@@ -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();
}
}
}
@@ -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;
}
@@ -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)
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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
}
}
@@ -0,0 +1,5 @@
package org.apache.fineract.portfolio.election.domain;
public enum AgendaResolution {
APPROVED, REJECTED, DEFERRED
}
@@ -0,0 +1,5 @@
package org.apache.fineract.portfolio.election.domain;
public enum AgendaVote {
APPROVED, REJECTED, ABSTAIN
}
@@ -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);
@@ -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;
}
}
@@ -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);
}
@@ -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;
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
@@ -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
@@ -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));
}
}
}
@@ -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();
}
@@ -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;
}
}
@@ -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",
@@ -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("🗳️&nbsp; 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(" &nbsp;·&nbsp; ").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>
@@ -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>
@@ -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>
@@ -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>