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:
co-authored by
Claude Sonnet 4.6
parent
e72b9359c1
commit
73db4ee114
+2
-1
@@ -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;
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+6
-4
@@ -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;
|
||||
}
|
||||
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
+11
-2
@@ -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);
|
||||
}
|
||||
|
||||
+281
-21
@@ -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 ? "✍" : "📅";
|
||||
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 › 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);'>👥 ").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("👥", "Committee", committeeName, false));
|
||||
sb.append(erow("📋", "Meeting Title", "<strong style='color:#1e293b;font-size:14px;'>" + meeting.getTitle() + "</strong>", false));
|
||||
sb.append(erow("📅", "Date", dateStr, false));
|
||||
sb.append(erow("⏰", "Time", timeStr, false));
|
||||
sb.append(erow("🔗", "Mode", isOnline ? "<span style='background:#dbeafe;color:#1d4ed8;padding:2px 10px;border-radius:20px;font-size:12px;font-weight:600;'>🌐 Online</span>" : "<span style='background:#dcfce7;color:#16a34a;padding:2px 10px;border-radius:20px;font-size:12px;font-weight:600;'>📍 Offline</span>", false));
|
||||
|
||||
if (isOnline) {
|
||||
if (notBlank(meeting.getPlatform())) sb.append(erow("📹", "Platform", meeting.getPlatform(), false));
|
||||
if (notBlank(meeting.getMeetingLink())) sb.append(erow("🔗", "Join Link", "<a href='" + meeting.getMeetingLink() + "' style='color:#1e40af;font-weight:600;'>Click to Join</a>", false));
|
||||
if (notBlank(meeting.getMeetingIdRef())) sb.append(erow("🔑", "Meeting ID", meeting.getMeetingIdRef(), false));
|
||||
if (notBlank(meeting.getMeetingPassword())) sb.append(erow("🔒", "Password", meeting.getMeetingPassword(), true));
|
||||
} else {
|
||||
if (notBlank(meeting.getVenue())) sb.append(erow("🏠", "Venue", meeting.getVenue(), false));
|
||||
if (notBlank(meeting.getAddress())) sb.append(erow("📍", "Address", meeting.getAddress(), false));
|
||||
if (notBlank(meeting.getCity())) sb.append(erow("🌍", "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;'>📋 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>
|
||||
|
||||
+45
@@ -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>
|
||||
Reference in New Issue
Block a user