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:
co-authored by
Claude Sonnet 4.6
parent
ed139d6e68
commit
3d30bdb788
+86
@@ -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")
|
||||
|
||||
+28
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
+7
@@ -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);
|
||||
}
|
||||
|
||||
+2
@@ -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);
|
||||
}
|
||||
|
||||
+10
-1
@@ -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) {
|
||||
|
||||
+4
@@ -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);
|
||||
|
||||
+31
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user