profileShare

rasmusjy / tasteprint

Read-only snapshot

No repository description.

main default branch 169 files Expires Sep 13, 2026, 9:06 AM
TastingService.java 6,170 bytes
1 package com.tasteprint.tasting;
2
3 import java.time.Clock;
4 import java.time.LocalDate;
5 import java.util.HashMap;
6 import java.util.HashSet;
7 import java.util.List;
8 import java.util.Locale;
9 import java.util.Map;
10 import java.util.Objects;
11 import java.util.Set;
12 import java.util.UUID;
13
14 import org.springframework.context.ApplicationEventPublisher;
15 import org.springframework.data.domain.Page;
16 import org.springframework.data.domain.PageRequest;
17 import org.springframework.stereotype.Service;
18 import org.springframework.transaction.annotation.Transactional;
19
20 import com.tasteprint.catalog.CatalogService;
21 import com.tasteprint.catalog.DishView;
22 import com.tasteprint.media.MediaStorage;
23 import com.tasteprint.shared.NotFoundException;
24
25 @Service
26 public class TastingService {
27
28 private final TastingRepository tastings;
29 private final CatalogService catalog;
30 private final ApplicationEventPublisher events;
31 private final MediaStorage mediaStorage;
32 private final Clock clock;
33
34 TastingService(TastingRepository tastings, CatalogService catalog,
35 ApplicationEventPublisher events, MediaStorage mediaStorage, Clock clock) {
36 this.tastings = tastings;
37 this.catalog = catalog;
38 this.events = events;
39 this.mediaStorage = mediaStorage;
40 this.clock = clock;
41 }
42
43 @Transactional
44 public TastingView record(UUID userId, SaveTastingRequest request) {
45 DishView dish = catalog.dish(request.dishSlug());
46 mediaStorage.requireUsableBy(userId, request.photoUrl());
47 String countryCode = normalizeCountryCode(request.countryCode());
48 Tasting tasting = tastings.save(new Tasting(
49 UUID.randomUUID(), userId, request, countryCode, clock.instant()
50 ));
51 events.publishEvent(new TastingRecorded(tasting.id(), userId, tasting.dishSlug(), tasting.tastedOn()));
52 return view(tasting, dish);
53 }
54
55 @Transactional
56 public TastingView update(UUID userId, UUID tastingId, SaveTastingRequest request) {
57 Tasting tasting = requiredOwned(tastingId, userId);
58 DishView dish = catalog.dish(request.dishSlug());
59 mediaStorage.requireUsableBy(userId, request.photoUrl());
60 String previousPhotoUrl = tasting.photoUrl();
61 tasting.update(request, normalizeCountryCode(request.countryCode()), clock.instant());
62 if (!Objects.equals(previousPhotoUrl, tasting.photoUrl())) {
63 tastings.flush();
64 deletePhotoIfUnused(userId, previousPhotoUrl);
65 }
66 return view(tasting, dish);
67 }
68
69 @Transactional
70 public void delete(UUID userId, UUID tastingId) {
71 Tasting tasting = requiredOwned(tastingId, userId);
72 String photoUrl = tasting.photoUrl();
73 tastings.delete(tasting);
74 tastings.flush();
75 deletePhotoIfUnused(userId, photoUrl);
76 }
77
78 @Transactional(readOnly = true)
79 public TastingPage page(UUID userId, int page, int size) {
80 int safePage = Math.max(page, 0);
81 int safeSize = Math.min(Math.max(size, 1), 50);
82 Page<Tasting> result = tastings.findByUserIdOrderByTastedOnDescCreatedAtDesc(
83 userId, PageRequest.of(safePage, safeSize)
84 );
85 List<TastingView> views = map(result.getContent());
86 return new TastingPage(
87 views, safePage, safeSize, result.getTotalElements(), result.getTotalPages(), result.hasNext()
88 );
89 }
90
91 @Transactional(readOnly = true)
92 public List<TastingView> recent(UUID userId) {
93 return map(tastings.findTop6ByUserIdOrderByTastedOnDescCreatedAtDesc(userId));
94 }
95
96 @Transactional(readOnly = true)
97 public Set<String> triedDishSlugs(UUID userId) {
98 return new HashSet<>(tastings.findDistinctDishSlugsByUserId(userId));
99 }
100
101 @Transactional(readOnly = true)
102 public Set<String> triedDishSlugsDuring(UUID userId, String countryCode,
103 LocalDate startsOn, LocalDate endsOn) {
104 return new HashSet<>(tastings.findDistinctDishSlugsDuring(
105 userId, normalizeCountryCode(countryCode), startsOn, endsOn
106 ));
107 }
108
109 @Transactional(readOnly = true)
110 public Set<String> triedDishSlugsBetween(UUID userId, LocalDate startsOn, LocalDate endsOn) {
111 return new HashSet<>(tastings.findDistinctDishSlugsBetween(userId, startsOn, endsOn));
112 }
113
114 @Transactional(readOnly = true)
115 public long count(UUID userId) {
116 return tastings.countByUserId(userId);
117 }
118
119 @Transactional(readOnly = true)
120 public boolean hasAny(UUID userId) {
121 return tastings.countByUserId(userId) > 0;
122 }
123
124 private List<TastingView> map(List<Tasting> entities) {
125 Map<String, DishView> dishBySlug = new HashMap<>();
126 for (Tasting tasting : entities) {
127 dishBySlug.computeIfAbsent(tasting.dishSlug(), catalog::dish);
128 }
129 return entities.stream()
130 .map(tasting -> view(tasting, dishBySlug.get(tasting.dishSlug())))
131 .toList();
132 }
133
134 private Tasting requiredOwned(UUID tastingId, UUID userId) {
135 return tastings.findByIdAndUserId(tastingId, userId)
136 .orElseThrow(() -> new NotFoundException("Tasting was not found."));
137 }
138
139 private String normalizeCountryCode(String countryCode) {
140 if (countryCode == null || !countryCode.matches("(?i)[a-z]{2}")) {
141 throw new IllegalArgumentException("Country must be a two-letter code.");
142 }
143 return countryCode.toUpperCase(Locale.ROOT);
144 }
145
146 private void deletePhotoIfUnused(UUID userId, String photoUrl) {
147 if (photoUrl != null && tastings.countByUserIdAndPhotoUrl(userId, photoUrl) == 0) {
148 mediaStorage.deleteOwned(userId, photoUrl);
149 }
150 }
151
152 private TastingView view(Tasting tasting, DishView dish) {
153 return new TastingView(
154 tasting.id(), dish, tasting.restaurantName(), tasting.city(), tasting.countryCode(),
155 tasting.tastedOn(), tasting.rating(), tasting.note(), tasting.photoUrl(), tasting.latitude(),
156 tasting.longitude(), tasting.createdAt()
157 );
158 }
159 }
160