feat(election): relational agenda items, email redesign, and stale-cache fix

- Add CommitteeMeetingAgenda entity + repository for relational agenda storage
- Liquibase migration 0353: create m_election_committee_meeting_agenda table,
  drop old agenda TEXT column from m_election_committee_meeting
- Update CommitteeMeeting: replace String agenda with List<CommitteeMeetingAgenda>
  (EAGER, no cascade — writes handled explicitly via repository)
- Update CommitteeMeetingRepository: explicit JPQL with SELECT DISTINCT + LEFT JOIN
  FETCH to load agendaItems and prevent NonUniqueResultException on Optional returns
- Fix email sending stale agenda: inject EntityManager, call refresh() after
  agendaRepository.flush() so EclipseLink reloads the collection from DB
- Redesign buildMeetingInviteHtml: table-based email layout, icon+label+value rows,
  amber agenda section with numbered badges, compact two-column footer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-06 16:53:32 +05:30
co-authored by Claude Sonnet 4.6
parent e72b9359c1
commit 73db4ee114
9 changed files with 404 additions and 29 deletions
@@ -5,6 +5,7 @@ import lombok.Data;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
@Data
@Builder
@@ -16,7 +17,7 @@ public class CommitteeMeetingData {
private String meetingTime;
private String mode;
private String status;
private String agenda;
private List<String> agendaItems;
// Online
private String platform;
private String meetingLink;
@@ -3,6 +3,7 @@ package org.apache.fineract.portfolio.election.data;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
@Data
public class CommitteeMeetingRequest {
@@ -11,7 +12,7 @@ public class CommitteeMeetingRequest {
private String meetingTime;
private String mode; // ONLINE | OFFLINE
private String status; // SCHEDULED | COMPLETED | CANCELLED
private String agenda;
private List<String> agendaItems;
// Online
private String platform;
private String meetingLink;
@@ -7,6 +7,8 @@ import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "m_election_committee_meeting")
@@ -35,8 +37,9 @@ public class CommitteeMeeting extends AbstractAuditableWithUTCDateTimeCustom<Lon
@Column(name = "status", nullable = false, length = 20)
private CommitteeMeetingStatus status = CommitteeMeetingStatus.SCHEDULED;
@Column(name = "agenda", columnDefinition = "TEXT")
private String agenda;
@OneToMany(mappedBy = "meeting", fetch = FetchType.EAGER)
@OrderBy("displayOrder ASC")
private List<CommitteeMeetingAgenda> agendaItems = new ArrayList<>();
// Online fields
@Column(name = "platform", length = 50)
@@ -65,14 +68,13 @@ public class CommitteeMeeting extends AbstractAuditableWithUTCDateTimeCustom<Lon
private boolean isDeleted = false;
public static CommitteeMeeting create(Long committeeId, String title, LocalDate meetingDate,
String meetingTime, CommitteeMeetingMode mode, String agenda) {
String meetingTime, CommitteeMeetingMode mode) {
CommitteeMeeting m = new CommitteeMeeting();
m.committeeId = committeeId;
m.title = title;
m.meetingDate = meetingDate;
m.meetingTime = meetingTime;
m.mode = mode;
m.agenda = agenda;
m.status = CommitteeMeetingStatus.SCHEDULED;
return m;
}
@@ -0,0 +1,41 @@
package org.apache.fineract.portfolio.election.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "m_election_committee_meeting_agenda")
@Getter
@Setter
@NoArgsConstructor
public class CommitteeMeetingAgenda {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// writable FK column — used by repository saves
@Column(name = "meeting_id", nullable = false)
private Long meetingId;
// read-only reference — used only by JOIN FETCH in JPQL queries
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "meeting_id", insertable = false, updatable = false)
private CommitteeMeeting meeting;
@Column(name = "agenda_item", nullable = false, columnDefinition = "TEXT")
private String agendaItem;
@Column(name = "display_order", nullable = false)
private int displayOrder;
public static CommitteeMeetingAgenda of(Long meetingId, String item, int order) {
CommitteeMeetingAgenda a = new CommitteeMeetingAgenda();
a.meetingId = meetingId;
a.agendaItem = item;
a.displayOrder = order;
return a;
}
}
@@ -0,0 +1,15 @@
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;
@Repository
public interface CommitteeMeetingAgendaRepository extends JpaRepository<CommitteeMeetingAgenda, Long> {
@Modifying
@Query("DELETE FROM CommitteeMeetingAgenda a WHERE a.meetingId = :meetingId")
void deleteByMeetingId(@Param("meetingId") Long meetingId);
}
@@ -1,14 +1,23 @@
package org.apache.fineract.portfolio.election.domain;
import org.springframework.data.jpa.repository.JpaRepository;
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 CommitteeMeetingRepository extends JpaRepository<CommitteeMeeting, Long> {
List<CommitteeMeeting> findByCommitteeIdAndIsDeletedFalseOrderByMeetingDateDesc(Long committeeId);
@Query("SELECT DISTINCT m FROM CommitteeMeeting m LEFT JOIN FETCH m.agendaItems " +
"WHERE m.committeeId = :committeeId AND m.isDeleted = false " +
"ORDER BY m.meetingDate DESC")
List<CommitteeMeeting> findByCommitteeIdAndIsDeletedFalseOrderByMeetingDateDesc(
@Param("committeeId") Long committeeId);
java.util.Optional<CommitteeMeeting> findByIdAndIsDeletedFalse(Long id);
@Query("SELECT DISTINCT m FROM CommitteeMeeting m LEFT JOIN FETCH m.agendaItems " +
"WHERE m.id = :id AND m.isDeleted = false")
Optional<CommitteeMeeting> findByIdAndIsDeletedFalse(@Param("id") Long id);
}
@@ -1,23 +1,53 @@
package org.apache.fineract.portfolio.election.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.apache.fineract.infrastructure.email.service.EmailSenderService;
import org.apache.fineract.portfolio.client.domain.ClientRepository;
import org.apache.fineract.portfolio.election.data.CommitteeMeetingData;
import org.apache.fineract.portfolio.election.data.CommitteeMeetingRequest;
import org.apache.fineract.portfolio.election.domain.Committee;
import org.apache.fineract.portfolio.election.domain.CommitteeRepository;
import org.apache.fineract.portfolio.election.domain.CommitteeMeeting;
import org.apache.fineract.portfolio.election.domain.CommitteeMeetingAgenda;
import org.apache.fineract.portfolio.election.domain.CommitteeMeetingAgendaRepository;
import org.apache.fineract.portfolio.election.domain.CommitteeMeetingMode;
import org.apache.fineract.portfolio.election.domain.CommitteeMeetingRepository;
import org.apache.fineract.portfolio.election.domain.CommitteeMeetingStatus;
import org.apache.fineract.portfolio.election.domain.CommitteeMember;
import org.apache.fineract.portfolio.election.domain.CommitteeMemberRepository;
import org.apache.fineract.portfolio.election.domain.DirectorAppointment;
import org.apache.fineract.portfolio.election.domain.DirectorAppointmentRepository;
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.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
private final CommitteeMeetingRepository repository;
private final CommitteeMeetingAgendaRepository agendaRepository;
private final CommitteeRepository committeeRepository;
private final CommitteeMemberRepository committeeMemberRepository;
private final DirectorAppointmentRepository directorAppointmentRepository;
private final ElectionCandidateRepository electionCandidateRepository;
private final ClientRepository clientRepository;
private final EmailSenderService emailSenderService;
@PersistenceContext
private EntityManager entityManager;
@Override
@Transactional
@@ -28,30 +58,70 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
req.getTitle(),
req.getMeetingDate(),
req.getMeetingTime(),
CommitteeMeetingMode.valueOf(req.getMode().toUpperCase()),
req.getAgenda());
CommitteeMeetingMode.valueOf(req.getMode().toUpperCase()));
applyModeFields(meeting, req);
return toData(repository.saveAndFlush(meeting));
// Save meeting first to get its generated ID
CommitteeMeeting saved = repository.saveAndFlush(meeting);
// Now save agenda items explicitly with the meeting ID
saveAgendaItems(saved.getId(), req.getAgendaItems());
agendaRepository.flush();
entityManager.refresh(saved); // reload agendaItems from DB — bypasses L1 identity map
CommitteeMeetingData result = toData(saved);
sendMeetingEmails(committeeId, saved, false);
return result;
}
@Override
@Transactional
public CommitteeMeetingData update(Long meetingId, CommitteeMeetingRequest req) {
CommitteeMeeting meeting = findOrThrow(meetingId);
if (req.getTitle() != null) meeting.setTitle(req.getTitle());
if (req.getMeetingDate() != null) meeting.setMeetingDate(req.getMeetingDate());
if (meeting.getStatus() != CommitteeMeetingStatus.SCHEDULED) {
throw new IllegalStateException("Only SCHEDULED meetings can be edited");
}
if (req.getTitle() != null) {
validateTitle(req.getTitle());
meeting.setTitle(req.getTitle());
}
if (req.getMeetingDate() != null) {
validateMeetingDate(req.getMeetingDate());
meeting.setMeetingDate(req.getMeetingDate());
}
if (req.getMeetingTime() != null) meeting.setMeetingTime(req.getMeetingTime());
if (req.getMode() != null) meeting.setMode(CommitteeMeetingMode.valueOf(req.getMode().toUpperCase()));
if (req.getAgenda() != null) meeting.setAgenda(req.getAgenda());
applyModeFields(meeting, req);
return toData(repository.save(meeting));
if (req.getMode() != null) {
meeting.setMode(CommitteeMeetingMode.valueOf(req.getMode().toUpperCase()));
applyModeFields(meeting, req);
}
repository.saveAndFlush(meeting);
// Replace agenda items if provided
if (req.getAgendaItems() != null) {
agendaRepository.deleteByMeetingId(meetingId);
agendaRepository.flush();
saveAgendaItems(meetingId, req.getAgendaItems());
agendaRepository.flush();
}
entityManager.refresh(meeting); // reload agendaItems from DB — bypasses L1 identity map
CommitteeMeetingData result = toData(meeting);
sendMeetingEmails(meeting.getCommitteeId(), meeting, true);
return result;
}
@Override
@Transactional
public CommitteeMeetingData patchStatus(Long meetingId, String status) {
CommitteeMeeting meeting = findOrThrow(meetingId);
meeting.setStatus(CommitteeMeetingStatus.valueOf(status.toUpperCase()));
CommitteeMeetingStatus newStatus;
try {
newStatus = CommitteeMeetingStatus.valueOf(status.toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid status. Use COMPLETED or CANCELLED");
}
if (newStatus == CommitteeMeetingStatus.SCHEDULED) {
throw new IllegalArgumentException("Cannot revert a meeting back to SCHEDULED");
}
if (meeting.getStatus() != CommitteeMeetingStatus.SCHEDULED) {
throw new IllegalStateException("Meeting is already " + meeting.getStatus().name() + " and cannot be changed");
}
meeting.setStatus(newStatus);
return toData(repository.save(meeting));
}
@@ -76,7 +146,177 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
.stream().map(this::toData).toList();
}
// -------------------------------------------------------------------------
// ── Agenda helpers ────────────────────────────────────────────────
private void saveAgendaItems(Long meetingId, List<String> items) {
if (items == null || items.isEmpty()) return;
List<CommitteeMeetingAgenda> toSave = new ArrayList<>();
int order = 0;
for (String raw : items) {
if (raw != null && !raw.isBlank()) {
toSave.add(CommitteeMeetingAgenda.of(meetingId, raw.trim(), order++));
}
}
if (!toSave.isEmpty()) {
agendaRepository.saveAll(toSave);
}
}
// ── Email ─────────────────────────────────────────────────────────────────
private void sendMeetingEmails(Long committeeId, CommitteeMeeting meeting, boolean isUpdate) {
List<CommitteeMember> members = committeeMemberRepository.findByCommitteeId(committeeId);
if (members.isEmpty()) return;
String committeeName = committeeRepository.findById(committeeId)
.map(Committee::getName).orElse(null);
String subject = (isUpdate ? "Meeting Updated: " : "Meeting Scheduled: ") + 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 memberId = candidate.getNomination().getMemberId();
if (memberId == null) continue;
String email = clientRepository.findById(memberId)
.map(c -> c.emailAddress())
.filter(e -> e != null && !e.isBlank())
.orElse(null);
if (email == null) continue;
String html = buildMeetingInviteHtml(appointment.getCandidateName(), committeeName, meeting, isUpdate);
boolean sent = emailSenderService.sendHtml(email, subject, html);
if (!sent) {
log.warn("Failed to send meeting email to {}", email);
}
} catch (Exception e) {
log.warn("Error sending meeting email to member {}: {}", member.getId(), e.getMessage());
}
}
}
private String buildMeetingInviteHtml(String recipientName, String committeeName, CommitteeMeeting meeting, boolean isUpdate) {
DateTimeFormatter dateFmt = DateTimeFormatter.ofPattern("dd MMMM yyyy");
String sentAt = java.time.LocalDateTime.now().format(DateTimeFormatter.ofPattern("dd MMM yyyy, hh:mm a"));
String dateStr = meeting.getMeetingDate() != null ? meeting.getMeetingDate().format(dateFmt) : "—";
String timeStr = meeting.getMeetingTime() != null ? meeting.getMeetingTime() : "—";
boolean isOnline = meeting.getMode() == CommitteeMeetingMode.ONLINE;
String accentColor = isUpdate ? "#7c3aed" : "#1e40af";
String headerIcon = isUpdate ? "&#9997;" : "&#128197;";
String headerLabel = isUpdate ? "Meeting Updated" : "Meeting Scheduled";
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;'>")
// ── Outer wrapper
.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:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.10);max-width:560px;'>")
// ── Header band
.append("<tr><td style='background:").append(accentColor).append(";padding:0;'>")
.append("<table width='100%' cellpadding='0' cellspacing='0'><tr>")
.append("<td style='padding:22px 28px;vertical-align:middle;'>")
.append("<div style='font-size:11px;font-weight:700;color:rgba(255,255,255,0.65);letter-spacing:1.5px;text-transform:uppercase;margin-bottom:4px;'>CBSnexora &nbsp;&#8250;&nbsp; Committee Meeting</div>")
.append("<div style='font-size:20px;font-weight:800;color:#fff;'>").append(headerIcon).append(" ").append(headerLabel).append("</div>");
if (notBlank(committeeName)) {
sb.append("<div style='margin-top:6px;font-size:12px;color:rgba(255,255,255,0.75);'>&#128101;&nbsp;").append(committeeName).append("</div>");
}
sb.append("</td>")
.append("<td style='padding:22px 28px 22px 0;text-align:right;vertical-align:top;'>")
.append("<div style='background:rgba(255,255,255,0.15);border-radius:8px;padding:8px 14px;display:inline-block;'>")
.append("<div style='font-size:10px;color:rgba(255,255,255,0.65);'>Status</div>")
.append("<div style='font-size:13px;font-weight:700;color:#fff;'>SCHEDULED</div>")
.append("</div></td></tr></table></td></tr>")
// ── Greeting
.append("<tr><td style='padding:24px 28px 0;'>")
.append("<p style='margin:0 0 4px;font-size:15px;color:#0f172a;'>Dear <strong>").append(recipientName).append("</strong>,</p>")
.append("<p style='margin:0;font-size:13px;color:#64748b;'>")
.append(isUpdate ? "The following committee meeting details have been updated. Please review the changes." : "You have been invited to attend a committee meeting. Please find the details below.")
.append("</p></td></tr>")
// ── Details card
.append("<tr><td style='padding:20px 28px;'>")
.append("<table width='100%' cellpadding='0' cellspacing='0' style='background:#f8faff;border-radius:10px;border:1px solid #e2e8f0;overflow:hidden;font-size:13px;'>");
// Details rows
if (notBlank(committeeName)) sb.append(erow("&#128101;", "Committee", committeeName, false));
sb.append(erow("&#128203;", "Meeting Title", "<strong style='color:#1e293b;font-size:14px;'>" + meeting.getTitle() + "</strong>", false));
sb.append(erow("&#128197;", "Date", dateStr, false));
sb.append(erow("&#9200;", "Time", timeStr, false));
sb.append(erow("&#128279;", "Mode", isOnline ? "<span style='background:#dbeafe;color:#1d4ed8;padding:2px 10px;border-radius:20px;font-size:12px;font-weight:600;'>&#127760; Online</span>" : "<span style='background:#dcfce7;color:#16a34a;padding:2px 10px;border-radius:20px;font-size:12px;font-weight:600;'>&#128205; Offline</span>", false));
if (isOnline) {
if (notBlank(meeting.getPlatform())) sb.append(erow("&#128249;", "Platform", meeting.getPlatform(), false));
if (notBlank(meeting.getMeetingLink())) sb.append(erow("&#128279;", "Join Link", "<a href='" + meeting.getMeetingLink() + "' style='color:#1e40af;font-weight:600;'>Click to Join</a>", false));
if (notBlank(meeting.getMeetingIdRef())) sb.append(erow("&#128273;", "Meeting ID", meeting.getMeetingIdRef(), false));
if (notBlank(meeting.getMeetingPassword())) sb.append(erow("&#128274;", "Password", meeting.getMeetingPassword(), true));
} else {
if (notBlank(meeting.getVenue())) sb.append(erow("&#127968;", "Venue", meeting.getVenue(), false));
if (notBlank(meeting.getAddress())) sb.append(erow("&#128205;", "Address", meeting.getAddress(), false));
if (notBlank(meeting.getCity())) sb.append(erow("&#127757;", "City", meeting.getCity(), true));
}
sb.append("</table></td></tr>");
// ── Agenda section
List<CommitteeMeetingAgenda> agendaItems = meeting.getAgendaItems();
if (agendaItems != null && !agendaItems.isEmpty()) {
sb.append("<tr><td style='padding:0 28px 20px;'>")
.append("<div style='background:#fefce8;border:1px solid #fde68a;border-radius:10px;padding:16px 20px;'>")
.append("<div style='font-size:13px;font-weight:700;color:#92400e;margin-bottom:10px;letter-spacing:0.3px;'>&#128203;&nbsp; AGENDA</div>")
.append("<table width='100%' cellpadding='0' cellspacing='0'>");
int i = 1;
for (CommitteeMeetingAgenda item : agendaItems) {
sb.append("<tr>")
.append("<td style='width:28px;vertical-align:top;padding:3px 0;'>")
.append("<span style='background:#f59e0b;color:#fff;border-radius:50%;width:20px;height:20px;display:inline-block;text-align:center;line-height:20px;font-size:11px;font-weight:700;'>").append(i++).append("</span>")
.append("</td>")
.append("<td style='padding:3px 0 3px 8px;font-size:13px;color:#1c1917;line-height:1.5;'>").append(item.getAgendaItem()).append("</td>")
.append("</tr>");
}
sb.append("</table></div></td></tr>");
}
// ── Footer
sb.append("<tr><td style='background:#f8fafc;border-top:1px solid #e2e8f0;padding:14px 28px;'>")
.append("<table width='100%' cellpadding='0' cellspacing='0'><tr>")
.append("<td style='font-size:11px;color:#94a3b8;'>This is an 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>")
.append("</td></tr>")
.append("</table>") // inner table
.append("</td></tr></table>") // outer wrapper
.append("</body></html>");
return sb.toString();
}
private String erow(String icon, String label, String value, boolean last) {
String borderStyle = last ? "" : "border-bottom:1px solid #e2e8f0;";
return "<tr>"
+ "<td style='padding:10px 14px;vertical-align:top;" + borderStyle + "width:36px;text-align:center;font-size:15px;'>" + icon + "</td>"
+ "<td style='padding:10px 0;vertical-align:top;" + borderStyle + "width:110px;font-size:12px;color:#64748b;font-weight:600;letter-spacing:0.3px;text-transform:uppercase;'>" + label + "</td>"
+ "<td style='padding:10px 14px 10px 0;vertical-align:top;" + borderStyle + "font-size:13px;color:#334155;'>" + (value != null ? value : "—") + "</td>"
+ "</tr>";
}
private boolean notBlank(String s) {
return s != null && !s.isBlank();
}
// ── Internal helpers ──────────────────────────────────────────────────────
private void applyModeFields(CommitteeMeeting m, CommitteeMeetingRequest req) {
CommitteeMeetingMode mode = m.getMode();
@@ -85,7 +325,6 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
m.setMeetingLink(req.getMeetingLink());
m.setMeetingIdRef(req.getMeetingIdRef());
m.setMeetingPassword(req.getMeetingPassword());
// clear offline fields
m.setVenue(null);
m.setAddress(null);
m.setCity(null);
@@ -93,7 +332,6 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
m.setVenue(req.getVenue());
m.setAddress(req.getAddress());
m.setCity(req.getCity());
// clear online fields
m.setPlatform(null);
m.setMeetingLink(null);
m.setMeetingIdRef(null);
@@ -102,20 +340,40 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
}
private void validateRequest(CommitteeMeetingRequest req) {
if (req.getTitle() == null || req.getTitle().isBlank()) {
throw new IllegalArgumentException("Meeting title is required");
}
if (req.getMeetingDate() == null) {
throw new IllegalArgumentException("Meeting date is required");
}
validateTitle(req.getTitle());
validateMeetingDate(req.getMeetingDate());
if (req.getMode() == null || req.getMode().isBlank()) {
throw new IllegalArgumentException("Meeting mode (ONLINE/OFFLINE) is required");
}
CommitteeMeetingMode mode;
try {
CommitteeMeetingMode.valueOf(req.getMode().toUpperCase());
mode = CommitteeMeetingMode.valueOf(req.getMode().toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid meeting mode. Use ONLINE or OFFLINE");
}
if (mode == CommitteeMeetingMode.ONLINE && req.getMeetingLink() != null && !req.getMeetingLink().isBlank()) {
if (!req.getMeetingLink().startsWith("http://") && !req.getMeetingLink().startsWith("https://")) {
throw new IllegalArgumentException("Meeting link must be a valid URL starting with http:// or https://");
}
}
}
private void validateTitle(String title) {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("Meeting title is required");
}
if (title.trim().length() > 200) {
throw new IllegalArgumentException("Meeting title must not exceed 200 characters");
}
}
private void validateMeetingDate(LocalDate date) {
if (date == null) {
throw new IllegalArgumentException("Meeting date is required");
}
if (date.isBefore(LocalDate.now())) {
throw new IllegalArgumentException("Meeting date cannot be in the past");
}
}
private CommitteeMeeting findOrThrow(Long id) {
@@ -125,6 +383,8 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
}
private CommitteeMeetingData toData(CommitteeMeeting m) {
List<String> agendaItems = m.getAgendaItems() == null ? Collections.emptyList()
: m.getAgendaItems().stream().map(CommitteeMeetingAgenda::getAgendaItem).toList();
return CommitteeMeetingData.builder()
.id(m.getId())
.committeeId(m.getCommitteeId())
@@ -133,7 +393,7 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
.meetingTime(m.getMeetingTime())
.mode(m.getMode() != null ? m.getMode().name() : null)
.status(m.getStatus() != null ? m.getStatus().name() : null)
.agenda(m.getAgenda())
.agendaItems(agendaItems)
.platform(m.getPlatform())
.meetingLink(m.getMeetingLink())
.meetingIdRef(m.getMeetingIdRef())
@@ -380,4 +380,5 @@
<include file="parts/0350_committee_image.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/0353_committee_meeting_agenda.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,45 @@
<?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="0353-committee-meeting-agenda-table" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_election_committee_meeting_agenda"/></not>
</preConditions>
<createTable tableName="m_election_committee_meeting_agenda">
<column name="id" type="BIGSERIAL">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="meeting_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="agenda_item" type="TEXT">
<constraints nullable="false"/>
</column>
<column name="display_order" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
</createTable>
<addForeignKeyConstraint
baseTableName="m_election_committee_meeting_agenda"
baseColumnNames="meeting_id"
referencedTableName="m_election_committee_meeting"
referencedColumnNames="id"
constraintName="fk_meeting_agenda_meeting"
onDelete="CASCADE"/>
<createIndex tableName="m_election_committee_meeting_agenda" indexName="idx_meeting_agenda_meeting_id">
<column name="meeting_id"/>
<column name="display_order"/>
</createIndex>
</changeSet>
<changeSet id="0353-drop-meeting-agenda-column" author="CBS">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_election_committee_meeting" columnName="agenda"/>
</preConditions>
<dropColumn tableName="m_election_committee_meeting" columnName="agenda"/>
</changeSet>
</databaseChangeLog>