profileShare

rasmusjy / tasteprint

Read-only snapshot

No repository description.

main default branch 169 files Expires Sep 13, 2026, 9:06 AM
ChallengeService.java 8,370 bytes
1 package com.tasteprint.social;
2
3 import java.security.SecureRandom;
4 import java.time.Clock;
5 import java.time.LocalDate;
6 import java.util.ArrayList;
7 import java.util.Comparator;
8 import java.util.HashSet;
9 import java.util.List;
10 import java.util.Locale;
11 import java.util.Set;
12 import java.util.UUID;
13
14 import org.springframework.stereotype.Service;
15 import org.springframework.transaction.annotation.Transactional;
16
17 import com.tasteprint.account.AccountService;
18 import com.tasteprint.catalog.CatalogService;
19 import com.tasteprint.catalog.DestinationDetails;
20 import com.tasteprint.catalog.DishView;
21 import com.tasteprint.progress.DishProgress;
22 import com.tasteprint.shared.ForbiddenException;
23 import com.tasteprint.shared.NotFoundException;
24 import com.tasteprint.tasting.TastingService;
25
26 @Service
27 public class ChallengeService {
28
29 private static final char[] JOIN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789".toCharArray();
30 private static final SecureRandom RANDOM = new SecureRandom();
31
32 private final TasteChallengeRepository challenges;
33 private final ChallengeParticipantRepository participants;
34 private final AccountService accounts;
35 private final CatalogService catalog;
36 private final TastingService tastings;
37 private final Clock clock;
38
39 ChallengeService(TasteChallengeRepository challenges, ChallengeParticipantRepository participants,
40 AccountService accounts, CatalogService catalog, TastingService tastings, Clock clock) {
41 this.challenges = challenges;
42 this.participants = participants;
43 this.accounts = accounts;
44 this.catalog = catalog;
45 this.tastings = tastings;
46 this.clock = clock;
47 }
48
49 @Transactional
50 public ChallengeView create(UUID userId, CreateChallengeRequest request) {
51 String destinationCode = request.destinationCode().toUpperCase(Locale.ROOT);
52 catalog.requireDestination(destinationCode);
53 TasteChallenge challenge = challenges.save(new TasteChallenge(
54 UUID.randomUUID(), userId, request, destinationCode, uniqueJoinCode(), clock.instant()
55 ));
56 participants.save(new ChallengeParticipant(UUID.randomUUID(), challenge.id(), userId, clock.instant()));
57 return view(challenge);
58 }
59
60 @Transactional
61 public ChallengeView join(UUID userId, String rawJoinCode) {
62 String joinCode = rawJoinCode.trim().toUpperCase(Locale.ROOT);
63 TasteChallenge challenge = challenges.findByJoinCode(joinCode)
64 .orElseThrow(() -> new NotFoundException("Challenge code was not found."));
65 if (LocalDate.now(clock).isAfter(challenge.endsOn())) {
66 throw new IllegalArgumentException("This challenge has already ended.");
67 }
68 if (!participants.existsByChallengeIdAndUserId(challenge.id(), userId)) {
69 participants.save(new ChallengeParticipant(UUID.randomUUID(), challenge.id(), userId, clock.instant()));
70 }
71 return view(challenge);
72 }
73
74 @Transactional(readOnly = true)
75 public List<ChallengeView> list(UUID userId) {
76 return participants.findByUserId(userId).stream()
77 .map(ChallengeParticipant::challengeId)
78 .distinct()
79 .map(challenges::findById)
80 .flatMap(java.util.Optional::stream)
81 .map(this::view)
82 .sorted(Comparator.comparing((ChallengeView challenge) -> statusOrder(challenge.status()))
83 .thenComparing(ChallengeView::startsOn))
84 .toList();
85 }
86
87 @Transactional(readOnly = true)
88 public ChallengeView get(UUID userId, UUID challengeId) {
89 TasteChallenge challenge = required(challengeId);
90 requireParticipant(challengeId, userId);
91 return view(challenge);
92 }
93
94 @Transactional
95 public void leave(UUID userId, UUID challengeId) {
96 TasteChallenge challenge = required(challengeId);
97 if (challenge.ownerId().equals(userId)) {
98 throw new IllegalArgumentException("The owner can delete the challenge but cannot leave it.");
99 }
100 ChallengeParticipant participant = participants.findByChallengeIdAndUserId(challengeId, userId)
101 .orElseThrow(() -> new NotFoundException("You are not part of this challenge."));
102 participants.delete(participant);
103 }
104
105 @Transactional
106 public void delete(UUID userId, UUID challengeId) {
107 TasteChallenge challenge = required(challengeId);
108 if (!challenge.ownerId().equals(userId)) {
109 throw new ForbiddenException("Only the challenge owner can delete it.");
110 }
111 challenges.delete(challenge);
112 }
113
114 @Transactional(readOnly = true)
115 public boolean hasAny(UUID userId) {
116 return !participants.findByUserId(userId).isEmpty();
117 }
118
119 private ChallengeView view(TasteChallenge challenge) {
120 DestinationDetails destination = catalog.destination(challenge.destinationCode());
121 Set<String> destinationSlugs = destination.dishes().stream()
122 .map(DishView::slug)
123 .collect(java.util.stream.Collectors.toSet());
124 Set<String> groupCompleted = new HashSet<>();
125 List<ChallengeParticipantView> memberViews = new ArrayList<>();
126
127 for (ChallengeParticipant participant : participants.findByChallengeId(challenge.id())) {
128 Set<String> completed = tastings.triedDishSlugsBetween(
129 participant.userId(), challenge.startsOn(), challenge.endsOn()
130 );
131 completed.retainAll(destinationSlugs);
132 groupCompleted.addAll(completed);
133 memberViews.add(new ChallengeParticipantView(
134 accounts.getPublicById(participant.userId()), completed.size(), completed.stream().sorted().toList()
135 ));
136 }
137 memberViews.sort(Comparator.comparingInt(ChallengeParticipantView::contributedDishes).reversed());
138
139 int totalWeight = destination.dishes().stream().mapToInt(DishView::importance).sum();
140 int completedWeight = destination.dishes().stream()
141 .filter(dish -> groupCompleted.contains(dish.slug()))
142 .mapToInt(DishView::importance)
143 .sum();
144 int coverage = totalWeight == 0 ? 0 : (int) Math.round(completedWeight * 100.0 / totalWeight);
145 List<DishProgress> dishProgress = destination.dishes().stream()
146 .map(dish -> new DishProgress(dish, groupCompleted.contains(dish.slug())))
147 .toList();
148
149 return new ChallengeView(
150 challenge.id(), challenge.ownerId(), challenge.title(), destination.destination(),
151 challenge.joinCode(), challenge.startsOn(), challenge.endsOn(), status(challenge),
152 coverage, dishProgress, memberViews
153 );
154 }
155
156 private TasteChallenge required(UUID challengeId) {
157 return challenges.findById(challengeId)
158 .orElseThrow(() -> new NotFoundException("Challenge was not found."));
159 }
160
161 private void requireParticipant(UUID challengeId, UUID userId) {
162 if (!participants.existsByChallengeIdAndUserId(challengeId, userId)) {
163 throw new ForbiddenException("Join the challenge before opening it.");
164 }
165 }
166
167 private ChallengeStatus status(TasteChallenge challenge) {
168 LocalDate today = LocalDate.now(clock);
169 if (today.isBefore(challenge.startsOn())) {
170 return ChallengeStatus.UPCOMING;
171 }
172 if (today.isAfter(challenge.endsOn())) {
173 return ChallengeStatus.COMPLETED;
174 }
175 return ChallengeStatus.ACTIVE;
176 }
177
178 private int statusOrder(ChallengeStatus status) {
179 return switch (status) {
180 case ACTIVE -> 0;
181 case UPCOMING -> 1;
182 case COMPLETED -> 2;
183 };
184 }
185
186 private String uniqueJoinCode() {
187 for (int attempt = 0; attempt < 30; attempt++) {
188 StringBuilder code = new StringBuilder(6);
189 for (int index = 0; index < 6; index++) {
190 code.append(JOIN_ALPHABET[RANDOM.nextInt(JOIN_ALPHABET.length)]);
191 }
192 if (!challenges.existsByJoinCode(code.toString())) {
193 return code.toString();
194 }
195 }
196 throw new IllegalStateException("Could not create a unique challenge code.");
197 }
198 }
199