diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeMeetingData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeMeetingData.java index 21e5c4903..bca69d689 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeMeetingData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeMeetingData.java @@ -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 agendaItems; // Online private String platform; private String meetingLink; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeMeetingRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeMeetingRequest.java index 2db87b1a2..e98e75caa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeMeetingRequest.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/CommitteeMeetingRequest.java @@ -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 agendaItems; // Online private String platform; private String meetingLink; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeeting.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeeting.java index b99d2567b..480b92e9d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeeting.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeeting.java @@ -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 agendaItems = new ArrayList<>(); // Online fields @Column(name = "platform", length = 50) @@ -65,14 +68,13 @@ public class CommitteeMeeting extends AbstractAuditableWithUTCDateTimeCustom { + + @Modifying + @Query("DELETE FROM CommitteeMeetingAgenda a WHERE a.meetingId = :meetingId") + void deleteByMeetingId(@Param("meetingId") Long meetingId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeetingRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeetingRepository.java index 0a3a7c5ec..3f72f0ca2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeetingRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/CommitteeMeetingRepository.java @@ -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 { - List 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 findByCommitteeIdAndIsDeletedFalseOrderByMeetingDateDesc( + @Param("committeeId") Long committeeId); - java.util.Optional findByIdAndIsDeletedFalse(Long id); + @Query("SELECT DISTINCT m FROM CommitteeMeeting m LEFT JOIN FETCH m.agendaItems " + + "WHERE m.id = :id AND m.isDeleted = false") + Optional findByIdAndIsDeletedFalse(@Param("id") Long id); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeMeetingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeMeetingServiceImpl.java index 46236a0db..ddd5bd20e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeMeetingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/CommitteeMeetingServiceImpl.java @@ -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 items) { + if (items == null || items.isEmpty()) return; + List 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 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("") + .append("") + + // ── Outer wrapper + .append("") + .append("
") + .append("") + + // ── Header band + .append("") + + // ── Greeting + .append("") + + // ── Details card + .append(""); + + // ── Agenda section + List agendaItems = meeting.getAgendaItems(); + if (agendaItems != null && !agendaItems.isEmpty()) { + sb.append(""); + } + + // ── Footer + sb.append("") + + .append("
") + .append("") + .append("") + .append("
") + .append("
CBSnexora  ›  Committee Meeting
") + .append("
").append(headerIcon).append(" ").append(headerLabel).append("
"); + if (notBlank(committeeName)) { + sb.append("
👥 ").append(committeeName).append("
"); + } + sb.append("
") + .append("
") + .append("
Status
") + .append("
SCHEDULED
") + .append("
") + .append("

Dear ").append(recipientName).append(",

") + .append("

") + .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("

") + .append(""); + + // Details rows + if (notBlank(committeeName)) sb.append(erow("👥", "Committee", committeeName, false)); + sb.append(erow("📋", "Meeting Title", "" + meeting.getTitle() + "", false)); + sb.append(erow("📅", "Date", dateStr, false)); + sb.append(erow("⏰", "Time", timeStr, false)); + sb.append(erow("🔗", "Mode", isOnline ? "🌐 Online" : "📍 Offline", false)); + + if (isOnline) { + if (notBlank(meeting.getPlatform())) sb.append(erow("📹", "Platform", meeting.getPlatform(), false)); + if (notBlank(meeting.getMeetingLink())) sb.append(erow("🔗", "Join Link", "Click to Join", 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("
") + .append("
") + .append("
📋  AGENDA
") + .append(""); + int i = 1; + for (CommitteeMeetingAgenda item : agendaItems) { + sb.append("") + .append("") + .append("") + .append(""); + } + sb.append("
") + .append("").append(i++).append("") + .append("").append(item.getAgendaItem()).append("
") + .append("") + .append("") + .append("") + .append("
This is an automated notification from CBSnexoraSent: ").append(sentAt).append("
") + .append("
") // inner table + .append("
") // outer wrapper + .append(""); + + return sb.toString(); + } + + private String erow(String icon, String label, String value, boolean last) { + String borderStyle = last ? "" : "border-bottom:1px solid #e2e8f0;"; + return "" + + "" + icon + "" + + "" + label + "" + + "" + (value != null ? value : "—") + "" + + ""; + } + + 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 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()) diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 504fc9738..91947cb9b 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -380,4 +380,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0353_committee_meeting_agenda.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0353_committee_meeting_agenda.xml new file mode 100644 index 000000000..615c79d1a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0353_committee_meeting_agenda.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +