PollService.java
package com.github.curriculeon.service;
import com.github.curriculeon.model.Poll;
import com.github.curriculeon.model.PollOption;
import com.github.curriculeon.model.Vote;
import com.github.curriculeon.repository.PollOptionRepository;
import com.github.curriculeon.repository.PollRepository;
import com.github.curriculeon.repository.VoteRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class PollService {
private final PollRepository pollRepository;
private final PollOptionRepository optionRepository;
private final VoteRepository voteRepository;
private final Clock clock;
@Autowired
public PollService(PollRepository pollRepository, PollOptionRepository optionRepository, VoteRepository voteRepository, Clock clock) {
this.pollRepository = pollRepository;
this.optionRepository = optionRepository;
this.voteRepository = voteRepository;
this.clock = clock;
}
public Poll createPoll(String question, List<String> optionTexts) {
if (optionTexts == null) throw new IllegalArgumentException("Options required");
// Trim and remove empty option entries (frontend allows empty optional option inputs)
List<String> texts = optionTexts.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
if (texts.size() < 2) throw new IllegalArgumentException("At least 2 options required");
if (texts.size() > 6) throw new IllegalArgumentException("At most 6 options allowed");
Instant now = Instant.now(clock);
Instant resultsVisibleAt = now.plus(Duration.ofHours(24));
Poll poll = new Poll(question, now, resultsVisibleAt);
for (String text : texts) {
poll.addOption(new PollOption(text));
}
return pollRepository.save(poll);
}
public Optional<Poll> findById(Long id) {
return pollRepository.findById(id);
}
public List<Poll> recentPolls() {
return pollRepository.findAllByOrderByCreatedAtDesc();
}
public boolean isResultsVisible(Poll poll) {
Instant now = Instant.now(clock);
return now.equals(poll.getResultsVisibleAt()) || now.isAfter(poll.getResultsVisibleAt());
}
/**
* Cast an anonymous vote. If fingerprint is provided, attempt simple duplicate prevention.
*/
public Vote vote(Long pollId, Long optionId, String fingerprint) {
Poll poll = pollRepository.findById(pollId).orElseThrow(() -> new NoSuchElementException("Poll not found"));
PollOption option = optionRepository.findById(optionId).orElseThrow(() -> new NoSuchElementException("Option not found"));
if (fingerprint != null && !fingerprint.trim().isEmpty()) {
if (voteRepository.existsByPoll_IdAndFingerprint(pollId, fingerprint)) {
throw new IllegalStateException("Duplicate vote detected for this fingerprint");
}
}
Vote vote = new Vote(poll, option, Instant.now(clock), fingerprint);
return voteRepository.save(vote);
}
public Map<Long, Long> countsForPoll(Poll poll) {
Map<Long, Long> map = new HashMap<>();
for (PollOption o : poll.getOptions()) {
long c = voteRepository.countByOption_Id(o.getId());
map.put(o.getId(), c);
}
return map;
}
/**
* Return true if the provided fingerprint has cast at least one vote for the given poll.
*/
public boolean hasVoted(Long pollId, String fingerprint) {
if (fingerprint == null || fingerprint.trim().isEmpty()) return false;
List<Vote> votes = voteRepository.findByPoll_IdAndFingerprint(pollId, fingerprint);
return votes != null && !votes.isEmpty();
}
}