feat(election): committee meeting module — schedule, online/offline mode, soft delete, audit logging

- CommitteeMeeting entity, enums (ONLINE/OFFLINE mode, SCHEDULED/COMPLETED/CANCELLED status), repository with soft delete filter
- CommitteeMeetingService + Impl: schedule, update, patchStatus, soft delete, retrieveByCommittee
- CommitteeMeetingApiResource: REST endpoints at /v1/election/committee-meetings with full audit logging
- ElectionAuditAction: add SCHEDULE_MEETING, UPDATE_MEETING, CANCEL_MEETING, COMPLETE_MEETING, DELETE_MEETING + FAILED variants
- CommitteeService: expose getElectionIdForCommittee() for audit context
- Liquibase 0351: create m_election_committee_meeting table with is_deleted soft delete column
- Liquibase 0352: rename created_date/last_modified_date → created_on_utc/last_modified_on_utc to match EclipseLink base class mapping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-07-06 10:16:20 +05:30
co-authored by Claude Sonnet 4.6
parent a1a31aff25
commit e72b9359c1
15 changed files with 579 additions and 1 deletions
@@ -0,0 +1,143 @@
package org.apache.fineract.portfolio.election.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.election.data.CommitteeMeetingRequest;
import org.apache.fineract.portfolio.election.data.CommitteeMeetingData;
import org.apache.fineract.portfolio.election.domain.ElectionAuditAction;
import org.apache.fineract.portfolio.election.service.CommitteeMeetingService;
import org.apache.fineract.portfolio.election.service.CommitteeService;
import org.apache.fineract.portfolio.election.service.ElectionAuditService;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
@Path("/v1/election/committee-meetings")
@Tag(name = "Committee Meetings", description = "Schedule and manage committee meetings")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@RequiredArgsConstructor
public class CommitteeMeetingApiResource {
private final CommitteeMeetingService meetingService;
private final CommitteeService committeeService;
private final ElectionAuditService auditService;
@GET
@Path("/committee/{committeeId}")
@Operation(summary = "List all meetings for a committee")
public Response listByCommittee(@PathParam("committeeId") Long committeeId) {
return Response.ok(meetingService.retrieveByCommittee(committeeId)).build();
}
@GET
@Path("/{id}")
@Operation(summary = "Get meeting detail")
public Response get(@PathParam("id") Long id) {
return Response.ok(meetingService.retrieve(id)).build();
}
@POST
@Path("/committee/{committeeId}")
@Operation(summary = "Schedule a new meeting for a committee")
public Response schedule(@PathParam("committeeId") Long committeeId,
CommitteeMeetingRequest request,
@Context HttpServletRequest httpRequest) {
Long electionId = committeeService.getElectionIdForCommittee(committeeId);
CommitteeMeetingData result;
try {
result = meetingService.schedule(committeeId, request);
} catch (RuntimeException e) {
auditService.logInNewTransaction(ElectionAuditAction.SCHEDULE_MEETING_FAILED,
"COMMITTEE", committeeId, electionId,
"Schedule meeting failed: " + e.getMessage(),
"committeeId=" + committeeId + ", title=" + request.getTitle(),
clientIp(httpRequest));
throw e;
}
auditService.log(ElectionAuditAction.SCHEDULE_MEETING,
"COMMITTEE", result.getId(), electionId,
"Meeting \"" + request.getTitle() + "\" scheduled for " + request.getMeetingDate(),
"mode=" + request.getMode() + ", date=" + request.getMeetingDate()
+ ", committeeId=" + committeeId,
clientIp(httpRequest));
return Response.ok(result).build();
}
@PUT
@Path("/{id}")
@Operation(summary = "Update a scheduled meeting")
public Response update(@PathParam("id") Long id,
CommitteeMeetingRequest request,
@Context HttpServletRequest httpRequest) {
CommitteeMeetingData existing = meetingService.retrieve(id);
Long electionId = committeeService.getElectionIdForCommittee(existing.getCommitteeId());
CommitteeMeetingData result;
try {
result = meetingService.update(id, request);
} catch (RuntimeException e) {
auditService.logInNewTransaction(ElectionAuditAction.UPDATE_MEETING_FAILED,
"COMMITTEE", id, electionId,
"Update meeting failed: " + e.getMessage(), null, clientIp(httpRequest));
throw e;
}
auditService.log(ElectionAuditAction.UPDATE_MEETING,
"COMMITTEE", id, electionId,
"Meeting \"" + result.getTitle() + "\" updated",
"mode=" + result.getMode() + ", date=" + result.getMeetingDate(),
clientIp(httpRequest));
return Response.ok(result).build();
}
@PATCH
@Path("/{id}/status")
@Operation(summary = "Change meeting status (COMPLETED or CANCELLED)")
public Response patchStatus(@PathParam("id") Long id,
Map<String, String> body,
@Context HttpServletRequest httpRequest) {
String status = body.get("status");
if (status == null || status.isBlank()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "status field is required")).build();
}
CommitteeMeetingData existing = meetingService.retrieve(id);
Long electionId = committeeService.getElectionIdForCommittee(existing.getCommitteeId());
CommitteeMeetingData result = meetingService.patchStatus(id, status);
ElectionAuditAction action = "COMPLETED".equalsIgnoreCase(status)
? ElectionAuditAction.COMPLETE_MEETING
: ElectionAuditAction.CANCEL_MEETING;
auditService.log(action, "COMMITTEE", id, electionId,
"Meeting \"" + existing.getTitle() + "\" marked as " + status.toUpperCase(),
"meetingId=" + id + ", newStatus=" + status.toUpperCase(),
clientIp(httpRequest));
return Response.ok(result).build();
}
@DELETE
@Path("/{id}")
@Operation(summary = "Delete a scheduled meeting")
public Response delete(@PathParam("id") Long id,
@Context HttpServletRequest httpRequest) {
CommitteeMeetingData existing = meetingService.retrieve(id);
Long electionId = committeeService.getElectionIdForCommittee(existing.getCommitteeId());
meetingService.delete(id);
auditService.log(ElectionAuditAction.DELETE_MEETING,
"COMMITTEE", id, electionId,
"Meeting \"" + existing.getTitle() + "\" deleted",
"meetingId=" + id + ", date=" + existing.getMeetingDate(),
clientIp(httpRequest));
return Response.ok(Map.of("resourceId", id)).build();
}
private String clientIp(HttpServletRequest req) {
String ip = req.getHeader("X-Forwarded-For");
return (ip != null && !ip.isBlank()) ? ip.split(",")[0].trim() : req.getRemoteAddr();
}
}
@@ -0,0 +1,32 @@
package org.apache.fineract.portfolio.election.data;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDate;
import java.time.OffsetDateTime;
@Data
@Builder
public class CommitteeMeetingData {
private Long id;
private Long committeeId;
private String title;
private LocalDate meetingDate;
private String meetingTime;
private String mode;
private String status;
private String agenda;
// Online
private String platform;
private String meetingLink;
private String meetingIdRef;
private String meetingPassword;
// Offline
private String venue;
private String address;
private String city;
// Audit
private OffsetDateTime createdDate;
private OffsetDateTime lastModifiedDate;
}
@@ -0,0 +1,24 @@
package org.apache.fineract.portfolio.election.data;
import lombok.Data;
import java.time.LocalDate;
@Data
public class CommitteeMeetingRequest {
private String title;
private LocalDate meetingDate;
private String meetingTime;
private String mode; // ONLINE | OFFLINE
private String status; // SCHEDULED | COMPLETED | CANCELLED
private String agenda;
// Online
private String platform;
private String meetingLink;
private String meetingIdRef;
private String meetingPassword;
// Offline
private String venue;
private String address;
private String city;
}
@@ -0,0 +1,79 @@
package org.apache.fineract.portfolio.election.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import java.time.LocalDate;
@Entity
@Table(name = "m_election_committee_meeting")
@Getter
@Setter
@NoArgsConstructor
public class CommitteeMeeting extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "committee_id", nullable = false)
private Long committeeId;
@Column(name = "title", nullable = false, length = 200)
private String title;
@Column(name = "meeting_date", nullable = false)
private LocalDate meetingDate;
@Column(name = "meeting_time", length = 10)
private String meetingTime;
@Enumerated(EnumType.STRING)
@Column(name = "mode", nullable = false, length = 20)
private CommitteeMeetingMode mode;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private CommitteeMeetingStatus status = CommitteeMeetingStatus.SCHEDULED;
@Column(name = "agenda", columnDefinition = "TEXT")
private String agenda;
// Online fields
@Column(name = "platform", length = 50)
private String platform;
@Column(name = "meeting_link", length = 500)
private String meetingLink;
@Column(name = "meeting_id_ref", length = 100)
private String meetingIdRef;
@Column(name = "meeting_password", length = 100)
private String meetingPassword;
// Offline fields
@Column(name = "venue", length = 200)
private String venue;
@Column(name = "address", columnDefinition = "TEXT")
private String address;
@Column(name = "city", length = 100)
private String city;
@Column(name = "is_deleted", nullable = false)
private boolean isDeleted = false;
public static CommitteeMeeting create(Long committeeId, String title, LocalDate meetingDate,
String meetingTime, CommitteeMeetingMode mode, String agenda) {
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,5 @@
package org.apache.fineract.portfolio.election.domain;
public enum CommitteeMeetingMode {
ONLINE, OFFLINE
}
@@ -0,0 +1,14 @@
package org.apache.fineract.portfolio.election.domain;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface CommitteeMeetingRepository extends JpaRepository<CommitteeMeeting, Long> {
List<CommitteeMeeting> findByCommitteeIdAndIsDeletedFalseOrderByMeetingDateDesc(Long committeeId);
java.util.Optional<CommitteeMeeting> findByIdAndIsDeletedFalse(Long id);
}
@@ -0,0 +1,5 @@
package org.apache.fineract.portfolio.election.domain;
public enum CommitteeMeetingStatus {
SCHEDULED, COMPLETED, CANCELLED
}
@@ -28,5 +28,8 @@ public enum ElectionAuditAction {
APPOINT_DIRECTOR_FAILED, UPDATE_DIRECTOR_APPOINTMENT_FAILED, REMOVE_DIRECTOR_APPOINTMENT_FAILED,
// Committee
CREATE_COMMITTEE, UPDATE_COMMITTEE, DISSOLVE_COMMITTEE,
ADD_COMMITTEE_MEMBER, REMOVE_COMMITTEE_MEMBER
ADD_COMMITTEE_MEMBER, REMOVE_COMMITTEE_MEMBER,
// Committee Meeting
SCHEDULE_MEETING, UPDATE_MEETING, CANCEL_MEETING, COMPLETE_MEETING, DELETE_MEETING,
SCHEDULE_MEETING_FAILED, UPDATE_MEETING_FAILED
}
@@ -0,0 +1,21 @@
package org.apache.fineract.portfolio.election.service;
import org.apache.fineract.portfolio.election.data.CommitteeMeetingData;
import org.apache.fineract.portfolio.election.data.CommitteeMeetingRequest;
import java.util.List;
public interface CommitteeMeetingService {
CommitteeMeetingData schedule(Long committeeId, CommitteeMeetingRequest request);
CommitteeMeetingData update(Long meetingId, CommitteeMeetingRequest request);
CommitteeMeetingData patchStatus(Long meetingId, String status);
void delete(Long meetingId);
CommitteeMeetingData retrieve(Long meetingId);
List<CommitteeMeetingData> retrieveByCommittee(Long committeeId);
}
@@ -0,0 +1,148 @@
package org.apache.fineract.portfolio.election.service;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
import org.apache.fineract.portfolio.election.data.CommitteeMeetingData;
import org.apache.fineract.portfolio.election.data.CommitteeMeetingRequest;
import org.apache.fineract.portfolio.election.domain.CommitteeMeeting;
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.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class CommitteeMeetingServiceImpl implements CommitteeMeetingService {
private final CommitteeMeetingRepository repository;
@Override
@Transactional
public CommitteeMeetingData schedule(Long committeeId, CommitteeMeetingRequest req) {
validateRequest(req);
CommitteeMeeting meeting = CommitteeMeeting.create(
committeeId,
req.getTitle(),
req.getMeetingDate(),
req.getMeetingTime(),
CommitteeMeetingMode.valueOf(req.getMode().toUpperCase()),
req.getAgenda());
applyModeFields(meeting, req);
return toData(repository.saveAndFlush(meeting));
}
@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 (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));
}
@Override
@Transactional
public CommitteeMeetingData patchStatus(Long meetingId, String status) {
CommitteeMeeting meeting = findOrThrow(meetingId);
meeting.setStatus(CommitteeMeetingStatus.valueOf(status.toUpperCase()));
return toData(repository.save(meeting));
}
@Override
@Transactional
public void delete(Long meetingId) {
CommitteeMeeting meeting = findOrThrow(meetingId);
meeting.setDeleted(true);
repository.save(meeting);
}
@Override
@Transactional(readOnly = true)
public CommitteeMeetingData retrieve(Long meetingId) {
return toData(findOrThrow(meetingId));
}
@Override
@Transactional(readOnly = true)
public List<CommitteeMeetingData> retrieveByCommittee(Long committeeId) {
return repository.findByCommitteeIdAndIsDeletedFalseOrderByMeetingDateDesc(committeeId)
.stream().map(this::toData).toList();
}
// -------------------------------------------------------------------------
private void applyModeFields(CommitteeMeeting m, CommitteeMeetingRequest req) {
CommitteeMeetingMode mode = m.getMode();
if (mode == CommitteeMeetingMode.ONLINE) {
m.setPlatform(req.getPlatform());
m.setMeetingLink(req.getMeetingLink());
m.setMeetingIdRef(req.getMeetingIdRef());
m.setMeetingPassword(req.getMeetingPassword());
// clear offline fields
m.setVenue(null);
m.setAddress(null);
m.setCity(null);
} else {
m.setVenue(req.getVenue());
m.setAddress(req.getAddress());
m.setCity(req.getCity());
// clear online fields
m.setPlatform(null);
m.setMeetingLink(null);
m.setMeetingIdRef(null);
m.setMeetingPassword(null);
}
}
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");
}
if (req.getMode() == null || req.getMode().isBlank()) {
throw new IllegalArgumentException("Meeting mode (ONLINE/OFFLINE) is required");
}
try {
CommitteeMeetingMode.valueOf(req.getMode().toUpperCase());
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("Invalid meeting mode. Use ONLINE or OFFLINE");
}
}
private CommitteeMeeting findOrThrow(Long id) {
return repository.findByIdAndIsDeletedFalse(id).orElseThrow(
() -> new AbstractPlatformResourceNotFoundException("error.msg.committee.meeting.not.found",
"Committee meeting not found with id: " + id, id) {});
}
private CommitteeMeetingData toData(CommitteeMeeting m) {
return CommitteeMeetingData.builder()
.id(m.getId())
.committeeId(m.getCommitteeId())
.title(m.getTitle())
.meetingDate(m.getMeetingDate())
.meetingTime(m.getMeetingTime())
.mode(m.getMode() != null ? m.getMode().name() : null)
.status(m.getStatus() != null ? m.getStatus().name() : null)
.agenda(m.getAgenda())
.platform(m.getPlatform())
.meetingLink(m.getMeetingLink())
.meetingIdRef(m.getMeetingIdRef())
.meetingPassword(m.getMeetingPassword())
.venue(m.getVenue())
.address(m.getAddress())
.city(m.getCity())
.createdDate(m.getCreatedDate().orElse(null))
.lastModifiedDate(m.getLastModifiedDate().orElse(null))
.build();
}
}
@@ -23,4 +23,6 @@ public interface CommitteeService {
CommitteeData removeMember(Long committeeId, Long directorAppointmentId);
List<DirectorAppointmentData> getCurrentElectionDirectors();
Long getElectionIdForCommittee(Long committeeId);
}
@@ -161,6 +161,13 @@ public class CommitteeServiceImpl implements CommitteeService {
.build()).toList();
}
@Override
public Long getElectionIdForCommittee(Long committeeId) {
return committeeRepository.findById(committeeId)
.map(this::electionIdForCommittee)
.orElse(null);
}
private Long electionIdFromDirectors(List<Long> directorAppointmentIds) {
if (directorAppointmentIds == null || directorAppointmentIds.isEmpty()) return null;
return directorRepository.findById(directorAppointmentIds.get(0))
@@ -378,4 +378,6 @@
<include file="parts/0348_election_config_duration_years.xml" relativeToChangelogFile="true"/>
<include file="parts/0349_election_committee.xml" relativeToChangelogFile="true"/>
<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"/>
</databaseChangeLog>
@@ -0,0 +1,66 @@
<?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="0351-committee-meeting" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_election_committee_meeting"/></not>
</preConditions>
<createTable tableName="m_election_committee_meeting">
<column name="id" type="BIGSERIAL">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="committee_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="title" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="meeting_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="meeting_time" type="VARCHAR(10)"/>
<column name="mode" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(20)" defaultValue="SCHEDULED">
<constraints nullable="false"/>
</column>
<column name="agenda" type="TEXT"/>
<!-- Online fields -->
<column name="platform" type="VARCHAR(50)"/>
<column name="meeting_link" type="VARCHAR(500)"/>
<column name="meeting_id_ref" type="VARCHAR(100)"/>
<column name="meeting_password" type="VARCHAR(100)"/>
<!-- Offline fields -->
<column name="venue" type="VARCHAR(200)"/>
<column name="address" type="TEXT"/>
<column name="city" type="VARCHAR(100)"/>
<!-- Soft delete -->
<column name="is_deleted" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<!-- Audit -->
<column name="created_by" type="BIGINT"/>
<column name="created_date" type="TIMESTAMP"/>
<column name="last_modified_by" type="BIGINT"/>
<column name="last_modified_date" type="TIMESTAMP"/>
</createTable>
<addForeignKeyConstraint
baseTableName="m_election_committee_meeting"
baseColumnNames="committee_id"
referencedTableName="m_election_committee"
referencedColumnNames="id"
constraintName="fk_committee_meeting_committee"/>
<createIndex tableName="m_election_committee_meeting" indexName="idx_committee_meeting_committee_date">
<column name="committee_id"/>
<column name="meeting_date" descending="true"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,27 @@
<?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="0352-committee-meeting-rename-created-date" author="CBS">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_election_committee_meeting" columnName="created_date"/>
</preConditions>
<renameColumn tableName="m_election_committee_meeting"
oldColumnName="created_date"
newColumnName="created_on_utc"
columnDataType="TIMESTAMP"/>
</changeSet>
<changeSet id="0352-committee-meeting-rename-modified-date" author="CBS">
<preConditions onFail="MARK_RAN">
<columnExists tableName="m_election_committee_meeting" columnName="last_modified_date"/>
</preConditions>
<renameColumn tableName="m_election_committee_meeting"
oldColumnName="last_modified_date"
newColumnName="last_modified_on_utc"
columnDataType="TIMESTAMP"/>
</changeSet>
</databaseChangeLog>