From 3d30bdb78870a53c624d3d6943b61a3e2fc4b2c0 Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Fri, 26 Jun 2026 18:39:28 +0530 Subject: [PATCH] feat(election): CBS ContentStore symbol upload, hasSymbol DTO, vote summary API - ElectionCandidateData: replace String symbol with Boolean hasSymbol + add memberId - ElectionCandidateApiResource: symbol upload/download via ContentStoreService (mimeType|storagePath format, backward-compatible with legacy base64 records) - ElectionVotingApiResource: add GET /vote-summary endpoint (totalVotes, totalVoters, per-candidate breakdown) using ElectionVoteRepository queries - ElectionVoteRepository: add countByElectionConfigId and voteSummaryByCandidate queries - ElectionReadPlatformServiceImpl: map hasSymbol and memberId in candidate list response - ElectionWritePlatformServiceImpl: set photoUrl from memberId on approveCandidate, delete old symbol file from ContentStore on removeCandidate Co-Authored-By: Claude Sonnet 4.6 --- .../api/ElectionCandidateApiResource.java | 86 +++++++++++++++++++ .../api/ElectionVotingApiResource.java | 29 ++++++- .../election/data/ElectionCandidateData.java | 3 +- .../election/domain/ElectionCandidate.java | 2 +- .../domain/ElectionVoteRepository.java | 7 ++ .../service/ElectionReadPlatformService.java | 2 + .../ElectionReadPlatformServiceImpl.java | 11 ++- .../service/ElectionWritePlatformService.java | 4 + .../ElectionWritePlatformServiceImpl.java | 31 +++++++ .../db/changelog/tenant/changelog-tenant.xml | 1 + 10 files changed, 172 insertions(+), 4 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java index ce41adcac..4bb4ca876 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionCandidateApiResource.java @@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.DELETE; import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; @@ -16,8 +17,15 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService; import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService; +import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService; +import org.glassfish.jersey.media.multipart.FormDataBodyPart; +import org.glassfish.jersey.media.multipart.FormDataContentDisposition; +import org.glassfish.jersey.media.multipart.FormDataParam; import org.springframework.stereotype.Component; +import java.io.IOException; +import java.io.InputStream; +import java.util.Base64; import java.util.Map; @Component @@ -31,6 +39,7 @@ public class ElectionCandidateApiResource { private final ElectionReadPlatformService readService; private final ElectionWritePlatformService writeService; private final PlatformSecurityContext context; + private final ContentStoreService contentStoreService; @GET @Path("/{electionId}/candidates") @@ -55,6 +64,83 @@ public class ElectionCandidateApiResource { return Response.ok(Map.of("resourceId", candidateId)).build(); } + @PATCH + @Path("/{electionId}/candidate/{candidateId}") + @Operation(summary = "Update candidate symbol or brief profile") + public Response updateCandidate(@PathParam("electionId") Long electionId, + @PathParam("candidateId") Long candidateId, + Map body) { + writeService.updateCandidate(candidateId, body.get("symbol"), body.get("briefProfile")); + return Response.ok(Map.of("resourceId", candidateId)).build(); + } + + @POST + @Path("/{electionId}/candidate/{candidateId}/symbol") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Upload election symbol image for a candidate") + public Response uploadCandidateSymbol(@PathParam("electionId") Long electionId, + @PathParam("candidateId") Long candidateId, + @FormDataParam("file") InputStream fileStream, + @FormDataParam("file") FormDataContentDisposition fileDetail, + @FormDataParam("file") FormDataBodyPart filePart) { + String mimeType = (filePart != null && filePart.getMediaType() != null) + ? filePart.getMediaType().toString() : "image/png"; + + // delete old file if it exists in new path format (safe — ignore failures) + String existing = readService.retrieveCandidateSymbol(candidateId); + if (existing != null && !existing.startsWith("data:") && existing.contains("|")) { + String[] ep = existing.split("\\|", 2); + if (ep.length == 2) { + try { contentStoreService.delete(ep[1]); } catch (Exception ignored) {} + } + } + + String ext = extractExt(fileDetail != null ? fileDetail.getFileName() : null); + String storagePath = "images" + contentStoreService.getDelimiter() + + "election_candidates" + contentStoreService.getDelimiter() + + candidateId + contentStoreService.getDelimiter() + + "symbol" + ext; + + // use the returned (sanitized/normalized) path, not the pre-computed one + String savedPath = contentStoreService.upload(storagePath, fileStream, mimeType); + writeService.uploadCandidateSymbol(candidateId, mimeType + "|" + savedPath); + return Response.ok(Map.of("resourceId", candidateId)).build(); + } + + @GET + @Path("/{electionId}/candidate/{candidateId}/symbol") + @Produces(MediaType.TEXT_PLAIN) + @Operation(summary = "Get election symbol for a candidate (returns base64 data URL)") + public Response getCandidateSymbol(@PathParam("electionId") Long electionId, + @PathParam("candidateId") Long candidateId) throws IOException { + String symbol = readService.retrieveCandidateSymbol(candidateId); + if (symbol == null) return Response.status(Response.Status.NOT_FOUND).build(); + + // legacy records: already a base64 data URL — return as-is + if (symbol.startsWith("data:")) { + return Response.ok(symbol).build(); + } + + // new format: "mimeType|storagePath" + String[] parts = symbol.split("\\|", 2); + if (parts.length < 2) return Response.status(Response.Status.NOT_FOUND).build(); + String mimeType = parts[0]; + String storagePath = parts[1]; + try { + byte[] bytes = contentStoreService.download(storagePath).readAllBytes(); + String base64 = "data:" + mimeType + ";base64," + Base64.getEncoder().encodeToString(bytes); + return Response.ok(base64).build(); + } catch (Exception e) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + } + + private static String extractExt(String filename) { + if (filename == null) return ".png"; + int dot = filename.lastIndexOf('.'); + return dot >= 0 ? filename.substring(dot).toLowerCase() : ".png"; + } + @DELETE @Path("/{electionId}/candidate/{candidateId}") @Operation(summary = "Remove a candidate from the election") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java index fadd4d96d..8dc260847 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/api/ElectionVotingApiResource.java @@ -8,16 +8,18 @@ import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.election.data.ElectionVoteRequest; +import org.apache.fineract.portfolio.election.domain.ElectionVoteRepository; import org.apache.fineract.portfolio.election.service.ElectionReadPlatformService; import org.apache.fineract.portfolio.election.service.ElectionWritePlatformService; import org.springframework.stereotype.Component; +import java.util.HashMap; +import java.util.List; import java.util.Map; @Component @@ -31,6 +33,7 @@ public class ElectionVotingApiResource { private final ElectionReadPlatformService readService; private final ElectionWritePlatformService writeService; private final PlatformSecurityContext context; + private final ElectionVoteRepository voteRepository; @POST @Path("/{electionId}/vote") @@ -57,4 +60,28 @@ public class ElectionVotingApiResource { public Response getResults(@PathParam("electionId") Long electionId) { return Response.ok(readService.retrieveResultsByElection(electionId)).build(); } + + @GET + @Path("/{electionId}/vote-summary") + @Operation(summary = "Get live vote counts per candidate per post") + public Response getVoteSummary(@PathParam("electionId") Long electionId) { + long totalVotes = voteRepository.countByElectionConfigId(electionId); + long totalVoters = voteRepository.countTotalVotersByElection(electionId); + List rows = voteRepository.voteSummaryByCandidate(electionId); + + List> breakdown = rows.stream().map(r -> { + Map entry = new HashMap<>(); + entry.put("postId", r[0]); + entry.put("candidateId", r[1]); + entry.put("voteCount", r[2]); + return entry; + }).collect(java.util.stream.Collectors.toList()); + + Map summary = new HashMap<>(); + summary.put("electionId", electionId); + summary.put("totalVotes", totalVotes); + summary.put("totalVoters", totalVoters); + summary.put("breakdown", breakdown); + return Response.ok(summary).build(); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionCandidateData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionCandidateData.java index 1ce5f569f..1b02927c5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionCandidateData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/data/ElectionCandidateData.java @@ -16,6 +16,7 @@ public class ElectionCandidateData { private Long id; private Long electionId; private Long nominationId; + private Long memberId; private Long postId; private String postName; private String candidateName; @@ -23,7 +24,7 @@ public class ElectionCandidateData { private String photoUrl; private Boolean isActive; private LocalDateTime approvedDate; - private String symbol; + private Boolean hasSymbol; private String briefProfile; private String gender; private String caste; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java index 656021dc4..cbab875d6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionCandidate.java @@ -45,7 +45,7 @@ public class ElectionCandidate extends AbstractPersistableCustom { @Column(name = "approved_date") private LocalDateTime approvedDate; - @Column(name = "symbol", length = 100) + @Column(name = "symbol", columnDefinition = "TEXT") private String symbol; @Column(name = "brief_profile", columnDefinition = "TEXT") diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionVoteRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionVoteRepository.java index b6d0e3af7..19cf8988a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionVoteRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/domain/ElectionVoteRepository.java @@ -22,4 +22,11 @@ public interface ElectionVoteRepository extends JpaRepository voteSummaryByCandidate(@Param("electionId") Long electionId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformService.java index f2ca32ffb..ce1617ec7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformService.java @@ -25,4 +25,6 @@ public interface ElectionReadPlatformService { PagedResponse retrieveCandidatesByPost(Long electionId, Long postId, int page, int size); List retrieveResultsByElection(Long electionId); + + String retrieveCandidateSymbol(Long candidateId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java index 465eff08f..09206d276 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionReadPlatformServiceImpl.java @@ -240,6 +240,7 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ .id(c.getId()) .electionId(electionId) .nominationId(c.getNomination().getId()) + .memberId(c.getNomination().getMemberId()) .postId(c.getElectionPost().getId()) .postName(c.getElectionPost().getPostName()) .candidateName(c.getCandidateName()) @@ -247,7 +248,7 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ .photoUrl(c.getPhotoUrl()) .isActive(c.getIsActive()) .approvedDate(c.getApprovedDate()) - .symbol(c.getSymbol()) + .hasSymbol(c.getSymbol() != null) .briefProfile(c.getBriefProfile()) .build()) .collect(Collectors.toList())) @@ -260,6 +261,14 @@ public class ElectionReadPlatformServiceImpl implements ElectionReadPlatformServ .build(); } + @Override + @Transactional(readOnly = true) + public String retrieveCandidateSymbol(Long candidateId) { + return candidateRepository.findById(candidateId) + .map(ElectionCandidate::getSymbol) + .orElse(null); + } + @Override @Transactional(readOnly = true) public List retrieveResultsByElection(Long electionId) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformService.java index b3a779efe..b6ea25a49 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformService.java @@ -39,6 +39,10 @@ public interface ElectionWritePlatformService { void removeCandidate(Long candidateId, String reason); + void updateCandidate(Long candidateId, String symbol, String briefProfile); + + void uploadCandidateSymbol(Long candidateId, String base64Symbol); + void castVote(ElectionVoteRequest request); void declareResults(Long electionId, Long declaredBy); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java index ef6047fef..cc0992641 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/election/service/ElectionWritePlatformServiceImpl.java @@ -34,6 +34,7 @@ import org.apache.fineract.portfolio.election.exception.ElectionNotActiveExcepti import org.apache.fineract.portfolio.election.exception.InvalidNominationStateException; import org.apache.fineract.portfolio.election.exception.NominationAlreadyExistsException; import org.apache.fineract.portfolio.election.exception.VoteAlreadyCastException; +import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -59,6 +60,7 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe private final ElectionPostRepository electionPostRepository; private final ElectionNominationRepository nominationRepository; private final ElectionCandidateRepository candidateRepository; + private final ContentStoreService contentStoreService; private final ElectionApprovalStepRepository approvalStepRepository; private final ElectionCeoPrecheckRepository ceoPrecheckRepository; private final ElectionVoteRepository voteRepository; @@ -384,6 +386,10 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe approvedBy != null ? approvedBy : context.authenticatedUser().getId() ); + if (nomination.getMemberId() != null) { + candidate.setPhotoUrl("/clients/" + nomination.getMemberId() + "/images"); + } + return candidateRepository.saveAndFlush(candidate).getId(); } @@ -393,10 +399,35 @@ public class ElectionWritePlatformServiceImpl implements ElectionWritePlatformSe context.authenticatedUser().validateHasPermissionTo("APPROVE_ELECTION_CANDIDATE"); ElectionCandidate candidate = candidateRepository.findById(candidateId) .orElseThrow(() -> new IllegalArgumentException("Candidate not found: " + candidateId)); + + String symbol = candidate.getSymbol(); + if (symbol != null && !symbol.startsWith("data:") && symbol.contains("|")) { + try { contentStoreService.delete(symbol.split("\\|", 2)[1]); } catch (Exception ignored) {} + } + candidate.deactivate(); candidateRepository.save(candidate); } + @Override + @Transactional + public void updateCandidate(Long candidateId, String symbol, String briefProfile) { + ElectionCandidate candidate = candidateRepository.findById(candidateId) + .orElseThrow(() -> new IllegalArgumentException("Candidate not found: " + candidateId)); + if (symbol != null) candidate.setSymbol(symbol); + if (briefProfile != null) candidate.setBriefProfile(briefProfile); + candidateRepository.save(candidate); + } + + @Override + @Transactional + public void uploadCandidateSymbol(Long candidateId, String base64Symbol) { + ElectionCandidate candidate = candidateRepository.findById(candidateId) + .orElseThrow(() -> new IllegalArgumentException("Candidate not found: " + candidateId)); + candidate.setSymbol(base64Symbol); + candidateRepository.save(candidate); + } + @Override @Transactional public void castVote(ElectionVoteRequest request) { 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 3fbb671fa..687367173 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 @@ -367,4 +367,5 @@ +