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.LocalDate;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@@ -16,7 +17,7 @@ public class CommitteeMeetingData {
|
|||||||
private String meetingTime;
|
private String meetingTime;
|
||||||
private String mode;
|
private String mode;
|
||||||
private String status;
|
private String status;
|
||||||
private String agenda;
|
private List<String> agendaItems;
|
||||||
// Online
|
// Online
|
||||||
private String platform;
|
private String platform;
|
||||||
private String meetingLink;
|
private String meetingLink;
|
||||||
|
|||||||
+2
-1
@@ -3,6 +3,7 @@ package org.apache.fineract.portfolio.election.data;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class CommitteeMeetingRequest {
|
public class CommitteeMeetingRequest {
|
||||||
@@ -11,7 +12,7 @@ public class CommitteeMeetingRequest {
|
|||||||
private String meetingTime;
|
private String meetingTime;
|
||||||
private String mode; // ONLINE | OFFLINE
|
private String mode; // ONLINE | OFFLINE
|
||||||
private String status; // SCHEDULED | COMPLETED | CANCELLED
|
private String status; // SCHEDULED | COMPLETED | CANCELLED
|
||||||
private String agenda;
|
private List<String> agendaItems;
|
||||||
// Online
|
// Online
|
||||||
private String platform;
|
private String platform;
|
||||||
private String meetingLink;
|
private String meetingLink;
|
||||||
|
|||||||
+6
-4
@@ -7,6 +7,8 @@ import lombok.Setter;
|
|||||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "m_election_committee_meeting")
|
@Table(name = "m_election_committee_meeting")
|
||||||
@@ -35,8 +37,9 @@ public class CommitteeMeeting extends AbstractAuditableWithUTCDateTimeCustom<Lon
|
|||||||
@Column(name = "status", nullable = false, length = 20)
|
@Column(name = "status", nullable = false, length = 20)
|
||||||
private CommitteeMeetingStatus status = CommitteeMeetingStatus.SCHEDULED;
|
private CommitteeMeetingStatus status = CommitteeMeetingStatus.SCHEDULED;
|
||||||
|
|
||||||
@Column(name = "agenda", columnDefinition = "TEXT")
|
@OneToMany(mappedBy = "meeting", fetch = FetchType.EAGER)
|
||||||
private String agenda;
|
@OrderBy("displayOrder ASC")
|
||||||
|
private List<CommitteeMeetingAgenda> agendaItems = new ArrayList<>();
|
||||||
|
|
||||||
// Online fields
|
// Online fields
|
||||||
@Column(name = "platform", length = 50)
|
@Column(name = "platform", length = 50)
|
||||||
@@ -65,14 +68,13 @@ public class CommitteeMeeting extends AbstractAuditableWithUTCDateTimeCustom<Lon
|
|||||||
private boolean isDeleted = false;
|
private boolean isDeleted = false;
|
||||||
|
|
||||||
public static CommitteeMeeting create(Long committeeId, String title, LocalDate meetingDate,
|
public static CommitteeMeeting create(Long committeeId, String title, LocalDate meetingDate,
|
||||||
String meetingTime, CommitteeMeetingMode mode, String agenda) {
|
String meetingTime, CommitteeMeetingMode mode) {
|
||||||
CommitteeMeeting m = new CommitteeMeeting();
|
CommitteeMeeting m = new CommitteeMeeting();
|
||||||
m.committeeId = committeeId;
|
m.committeeId = committeeId;
|
||||||
m.title = title;
|
m.title = title;
|
||||||
m.meetingDate = meetingDate;
|
m.meetingDate = meetingDate;
|
||||||
m.meetingTime = meetingTime;
|
m.meetingTime = meetingTime;
|
||||||
m.mode = mode;
|
m.mode = mode;
|
||||||
m.agenda = agenda;
|
|
||||||
m.status = CommitteeMeetingStatus.SCHEDULED;
|
m.status = CommitteeMeetingStatus.SCHEDULED;
|
||||||
return m;
|
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;
|
package org.apache.fineract.portfolio.election.domain;
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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 org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public interface CommitteeMeetingRepository extends JpaRepository<CommitteeMeeting, Long> {
|
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;
|
package org.apache.fineract.portfolio.election.service;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
|
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.CommitteeMeetingData;
|
||||||
import org.apache.fineract.portfolio.election.data.CommitteeMeetingRequest;
|
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.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.CommitteeMeetingMode;
|
||||||
import org.apache.fineract.portfolio.election.domain.CommitteeMeetingRepository;
|
import org.apache.fineract.portfolio.election.domain.CommitteeMeetingRepository;
|
||||||
import org.apache.fineract.portfolio.election.domain.CommitteeMeetingStatus;
|
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.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
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;
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
|
public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
|
||||||
|
|
||||||
private final CommitteeMeetingRepository repository;
|
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
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -28,30 +58,70 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
|
|||||||
req.getTitle(),
|
req.getTitle(),
|
||||||
req.getMeetingDate(),
|
req.getMeetingDate(),
|
||||||
req.getMeetingTime(),
|
req.getMeetingTime(),
|
||||||
CommitteeMeetingMode.valueOf(req.getMode().toUpperCase()),
|
CommitteeMeetingMode.valueOf(req.getMode().toUpperCase()));
|
||||||
req.getAgenda());
|
|
||||||
applyModeFields(meeting, req);
|
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
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public CommitteeMeetingData update(Long meetingId, CommitteeMeetingRequest req) {
|
public CommitteeMeetingData update(Long meetingId, CommitteeMeetingRequest req) {
|
||||||
CommitteeMeeting meeting = findOrThrow(meetingId);
|
CommitteeMeeting meeting = findOrThrow(meetingId);
|
||||||
if (req.getTitle() != null) meeting.setTitle(req.getTitle());
|
if (meeting.getStatus() != CommitteeMeetingStatus.SCHEDULED) {
|
||||||
if (req.getMeetingDate() != null) meeting.setMeetingDate(req.getMeetingDate());
|
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.getMeetingTime() != null) meeting.setMeetingTime(req.getMeetingTime());
|
||||||
if (req.getMode() != null) meeting.setMode(CommitteeMeetingMode.valueOf(req.getMode().toUpperCase()));
|
if (req.getMode() != null) {
|
||||||
if (req.getAgenda() != null) meeting.setAgenda(req.getAgenda());
|
meeting.setMode(CommitteeMeetingMode.valueOf(req.getMode().toUpperCase()));
|
||||||
applyModeFields(meeting, req);
|
applyModeFields(meeting, req);
|
||||||
return toData(repository.save(meeting));
|
}
|
||||||
|
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
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public CommitteeMeetingData patchStatus(Long meetingId, String status) {
|
public CommitteeMeetingData patchStatus(Long meetingId, String status) {
|
||||||
CommitteeMeeting meeting = findOrThrow(meetingId);
|
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));
|
return toData(repository.save(meeting));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +146,177 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
|
|||||||
.stream().map(this::toData).toList();
|
.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) {
|
private void applyModeFields(CommitteeMeeting m, CommitteeMeetingRequest req) {
|
||||||
CommitteeMeetingMode mode = m.getMode();
|
CommitteeMeetingMode mode = m.getMode();
|
||||||
@@ -85,7 +325,6 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
|
|||||||
m.setMeetingLink(req.getMeetingLink());
|
m.setMeetingLink(req.getMeetingLink());
|
||||||
m.setMeetingIdRef(req.getMeetingIdRef());
|
m.setMeetingIdRef(req.getMeetingIdRef());
|
||||||
m.setMeetingPassword(req.getMeetingPassword());
|
m.setMeetingPassword(req.getMeetingPassword());
|
||||||
// clear offline fields
|
|
||||||
m.setVenue(null);
|
m.setVenue(null);
|
||||||
m.setAddress(null);
|
m.setAddress(null);
|
||||||
m.setCity(null);
|
m.setCity(null);
|
||||||
@@ -93,7 +332,6 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
|
|||||||
m.setVenue(req.getVenue());
|
m.setVenue(req.getVenue());
|
||||||
m.setAddress(req.getAddress());
|
m.setAddress(req.getAddress());
|
||||||
m.setCity(req.getCity());
|
m.setCity(req.getCity());
|
||||||
// clear online fields
|
|
||||||
m.setPlatform(null);
|
m.setPlatform(null);
|
||||||
m.setMeetingLink(null);
|
m.setMeetingLink(null);
|
||||||
m.setMeetingIdRef(null);
|
m.setMeetingIdRef(null);
|
||||||
@@ -102,20 +340,40 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void validateRequest(CommitteeMeetingRequest req) {
|
private void validateRequest(CommitteeMeetingRequest req) {
|
||||||
if (req.getTitle() == null || req.getTitle().isBlank()) {
|
validateTitle(req.getTitle());
|
||||||
throw new IllegalArgumentException("Meeting title is required");
|
validateMeetingDate(req.getMeetingDate());
|
||||||
}
|
|
||||||
if (req.getMeetingDate() == null) {
|
|
||||||
throw new IllegalArgumentException("Meeting date is required");
|
|
||||||
}
|
|
||||||
if (req.getMode() == null || req.getMode().isBlank()) {
|
if (req.getMode() == null || req.getMode().isBlank()) {
|
||||||
throw new IllegalArgumentException("Meeting mode (ONLINE/OFFLINE) is required");
|
throw new IllegalArgumentException("Meeting mode (ONLINE/OFFLINE) is required");
|
||||||
}
|
}
|
||||||
|
CommitteeMeetingMode mode;
|
||||||
try {
|
try {
|
||||||
CommitteeMeetingMode.valueOf(req.getMode().toUpperCase());
|
mode = CommitteeMeetingMode.valueOf(req.getMode().toUpperCase());
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
throw new IllegalArgumentException("Invalid meeting mode. Use ONLINE or OFFLINE");
|
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) {
|
private CommitteeMeeting findOrThrow(Long id) {
|
||||||
@@ -125,6 +383,8 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private CommitteeMeetingData toData(CommitteeMeeting m) {
|
private CommitteeMeetingData toData(CommitteeMeeting m) {
|
||||||
|
List<String> agendaItems = m.getAgendaItems() == null ? Collections.emptyList()
|
||||||
|
: m.getAgendaItems().stream().map(CommitteeMeetingAgenda::getAgendaItem).toList();
|
||||||
return CommitteeMeetingData.builder()
|
return CommitteeMeetingData.builder()
|
||||||
.id(m.getId())
|
.id(m.getId())
|
||||||
.committeeId(m.getCommitteeId())
|
.committeeId(m.getCommitteeId())
|
||||||
@@ -133,7 +393,7 @@ public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
|
|||||||
.meetingTime(m.getMeetingTime())
|
.meetingTime(m.getMeetingTime())
|
||||||
.mode(m.getMode() != null ? m.getMode().name() : null)
|
.mode(m.getMode() != null ? m.getMode().name() : null)
|
||||||
.status(m.getStatus() != null ? m.getStatus().name() : null)
|
.status(m.getStatus() != null ? m.getStatus().name() : null)
|
||||||
.agenda(m.getAgenda())
|
.agendaItems(agendaItems)
|
||||||
.platform(m.getPlatform())
|
.platform(m.getPlatform())
|
||||||
.meetingLink(m.getMeetingLink())
|
.meetingLink(m.getMeetingLink())
|
||||||
.meetingIdRef(m.getMeetingIdRef())
|
.meetingIdRef(m.getMeetingIdRef())
|
||||||
|
|||||||
@@ -380,4 +380,5 @@
|
|||||||
<include file="parts/0350_committee_image.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0350_committee_image.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0351_committee_meeting.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0351_committee_meeting.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0352_committee_meeting_rename_audit_cols.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0352_committee_meeting_rename_audit_cols.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0353_committee_meeting_agenda.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</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