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 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-26 18:39:28 +05:30
co-authored by Claude Sonnet 4.6
parent ed139d6e68
commit 3d30bdb788
10 changed files with 172 additions and 4 deletions
@@ -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<String, String> 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")
@@ -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<Object[]> rows = voteRepository.voteSummaryByCandidate(electionId);
List<Map<String, Object>> breakdown = rows.stream().map(r -> {
Map<String, Object> 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<String, Object> summary = new HashMap<>();
summary.put("electionId", electionId);
summary.put("totalVotes", totalVotes);
summary.put("totalVoters", totalVoters);
summary.put("breakdown", breakdown);
return Response.ok(summary).build();
}
}
@@ -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;
@@ -45,7 +45,7 @@ public class ElectionCandidate extends AbstractPersistableCustom<Long> {
@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")
@@ -22,4 +22,11 @@ public interface ElectionVoteRepository extends JpaRepository<ElectionVote, Long
@Query("SELECT COUNT(DISTINCT v.voterMemberId) FROM ElectionVote v " +
"WHERE v.electionConfig.id = :electionId")
long countTotalVotersByElection(@Param("electionId") Long electionId);
long countByElectionConfigId(Long electionId);
@Query("SELECT v.electionPost.id, v.candidate.id, COUNT(v) " +
"FROM ElectionVote v WHERE v.electionConfig.id = :electionId " +
"GROUP BY v.electionPost.id, v.candidate.id ORDER BY v.electionPost.id, COUNT(v) DESC")
List<Object[]> voteSummaryByCandidate(@Param("electionId") Long electionId);
}
@@ -25,4 +25,6 @@ public interface ElectionReadPlatformService {
PagedResponse<ElectionCandidateData> retrieveCandidatesByPost(Long electionId, Long postId, int page, int size);
List<ElectionResultData> retrieveResultsByElection(Long electionId);
String retrieveCandidateSymbol(Long candidateId);
}
@@ -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<ElectionResultData> retrieveResultsByElection(Long electionId) {
@@ -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);
@@ -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) {
@@ -367,4 +367,5 @@
<include file="parts/0337_create_election_module.xml" relativeToChangelogFile="true"/>
<include file="parts/0338_election_ceo_precheck_extended_checks.xml" relativeToChangelogFile="true"/>
<include file="parts/0339_election_ceo_precheck_family_loan_check.xml" relativeToChangelogFile="true"/>
<include file="parts/0340_election_candidate_symbol_text.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>