StatisticsController.java
package edu.ucsb.cs156.dining.controllers;
import edu.ucsb.cs156.dining.entities.MenuItem;
import edu.ucsb.cs156.dining.entities.Review;
import edu.ucsb.cs156.dining.models.CommonsAverage;
import edu.ucsb.cs156.dining.models.CommonsAverageOverTime;
import edu.ucsb.cs156.dining.models.ItemStatistic;
import edu.ucsb.cs156.dining.models.MealAverage;
import edu.ucsb.cs156.dining.repositories.ReviewRepository;
import edu.ucsb.cs156.dining.statuses.ModerationStatus;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* REST controller that exposes aggregated review statistics. These endpoints power the "Review
* Statistics" pages of the frontend (see issue #18).
*
* <p>Only reviews with moderation status {@link ModerationStatus#APPROVED} (or reviews that have no
* comments and were therefore auto-approved) are considered when computing the statistics, so that
* unmoderated user content does not influence what we publish.
*/
@Tag(name = "Statistics")
@RequestMapping("/api/statistics")
@RestController
@Slf4j
public class StatisticsController extends ApiController {
@Autowired ReviewRepository reviewRepository;
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, String>> handleValidationExceptions(
IllegalArgumentException ex) {
Map<String, String> errors = new HashMap<>();
errors.put("error", ex.getMessage());
return ResponseEntity.badRequest().body(errors);
}
/** Supported time-period filters for the best/worst items endpoints. */
public static final String PERIOD_ALL = "ALL";
public static final String PERIOD_6M = "6M";
public static final String PERIOD_1M = "1M";
public static final String PERIOD_1W = "1W";
private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
/**
* Returns the cutoff {@link LocalDateTime} for the supplied period. {@code ALL} (or any
* unrecognised value) maps to {@code null}, meaning no lower bound.
*/
static LocalDateTime cutoffForPeriod(String period, LocalDateTime now) {
if (period == null) {
return null;
}
switch (period) {
case PERIOD_6M:
return now.minusMonths(6);
case PERIOD_1M:
return now.minusMonths(1);
case PERIOD_1W:
return now.minusWeeks(1);
case PERIOD_ALL:
default:
return null;
}
}
/** Loads only approved reviews from the repository (the only ones we expose in statistics). */
private List<Review> approvedReviews() {
List<Review> approved = new ArrayList<>();
for (Review r : reviewRepository.findByStatus(ModerationStatus.APPROVED)) {
approved.add(r);
}
return approved;
}
/** Applies a "served on or after the cutoff" filter (if a cutoff was given). */
private List<Review> filterByCutoff(List<Review> reviews, LocalDateTime cutoff) {
if (cutoff == null) {
return reviews;
}
List<Review> filtered = new ArrayList<>();
for (Review r : reviews) {
if (r.getDateItemServed() != null && !r.getDateItemServed().isBefore(cutoff)) {
filtered.add(r);
}
}
return filtered;
}
/** Groups reviews by their associated menu item id and computes aggregate statistics. */
private List<ItemStatistic> aggregateByItem(List<Review> reviews) {
Map<Long, List<Review>> byItem = new HashMap<>();
for (Review r : reviews) {
MenuItem item = r.getItem();
if (item == null) {
continue;
}
byItem.computeIfAbsent(item.getId(), k -> new ArrayList<>()).add(r);
}
List<ItemStatistic> stats = new ArrayList<>();
for (Map.Entry<Long, List<Review>> entry : byItem.entrySet()) {
List<Review> itemReviews = entry.getValue();
MenuItem item = itemReviews.get(0).getItem();
double total = 0.0;
long count = 0L;
for (Review r : itemReviews) {
if (r.getItemsStars() != null) {
total += r.getItemsStars();
count++;
}
}
if (count == 0L) {
continue;
}
stats.add(
ItemStatistic.builder()
.itemId(entry.getKey())
.itemName(item.getName())
.diningCommonsCode(item.getDiningCommonsCode())
.mealCode(item.getMealCode())
.station(item.getStation())
.averageStars(total / count)
.reviewCount(count)
.build());
}
return stats;
}
/** Rejects negative limits before they reach {@code Stream.limit}, which throws. */
private void validateLimit(int limit) {
if (limit < 0) {
throw new IllegalArgumentException("limit must be non-negative");
}
}
/** Best items endpoint, supports a time period filter and a maximum result count. */
@Operation(summary = "Best rated items, optionally restricted to a recent time period")
@PreAuthorize("hasRole('ROLE_USER')")
@GetMapping("/items/best")
public List<ItemStatistic> bestItems(
@Parameter(name = "period", description = "ALL, 6M, 1M, or 1W")
@RequestParam(name = "period", defaultValue = PERIOD_ALL)
String period,
@Parameter(name = "limit", description = "Maximum number of items to return")
@RequestParam(name = "limit", defaultValue = "5")
int limit) {
log.info("statistics.bestItems period={} limit={}", period, limit);
validateLimit(limit);
LocalDateTime cutoff = cutoffForPeriod(period, LocalDateTime.now());
List<ItemStatistic> stats = aggregateByItem(filterByCutoff(approvedReviews(), cutoff));
stats.sort(
Comparator.comparingDouble(ItemStatistic::getAverageStars)
.reversed()
.thenComparing(Comparator.comparingLong(ItemStatistic::getReviewCount).reversed())
.thenComparing(Comparator.comparing(ItemStatistic::getItemId)));
return stats.stream().limit(limit).collect(Collectors.toCollection(ArrayList::new));
}
/** Worst items endpoint, supports a time period filter and a maximum result count. */
@Operation(summary = "Worst rated items, optionally restricted to a recent time period")
@PreAuthorize("hasRole('ROLE_USER')")
@GetMapping("/items/worst")
public List<ItemStatistic> worstItems(
@Parameter(name = "period", description = "ALL, 6M, 1M, or 1W")
@RequestParam(name = "period", defaultValue = PERIOD_ALL)
String period,
@Parameter(name = "limit", description = "Maximum number of items to return")
@RequestParam(name = "limit", defaultValue = "5")
int limit) {
log.info("statistics.worstItems period={} limit={}", period, limit);
validateLimit(limit);
LocalDateTime cutoff = cutoffForPeriod(period, LocalDateTime.now());
List<ItemStatistic> stats = aggregateByItem(filterByCutoff(approvedReviews(), cutoff));
stats.sort(
Comparator.comparingDouble(ItemStatistic::getAverageStars)
.thenComparing(Comparator.comparingLong(ItemStatistic::getReviewCount).reversed())
.thenComparing(Comparator.comparing(ItemStatistic::getItemId)));
return stats.stream().limit(limit).collect(Collectors.toCollection(ArrayList::new));
}
/** Average review score for each dining commons. */
@Operation(summary = "Average review score for each dining commons")
@PreAuthorize("hasRole('ROLE_USER')")
@GetMapping("/commons/averages")
public List<CommonsAverage> commonsAverages() {
log.info("statistics.commonsAverages");
Map<String, double[]> byCommons = new HashMap<>();
for (Review r : approvedReviews()) {
MenuItem item = r.getItem();
if (item == null || item.getDiningCommonsCode() == null || r.getItemsStars() == null) {
continue;
}
double[] acc = byCommons.computeIfAbsent(item.getDiningCommonsCode(), k -> new double[2]);
acc[0] += r.getItemsStars();
acc[1] += 1.0;
}
List<CommonsAverage> result = new ArrayList<>();
for (Map.Entry<String, double[]> entry : byCommons.entrySet()) {
double[] acc = entry.getValue();
long count = (long) acc[1];
result.add(
CommonsAverage.builder()
.diningCommonsCode(entry.getKey())
.averageStars(acc[0] / acc[1])
.reviewCount(count)
.build());
}
result.sort(Comparator.comparing(CommonsAverage::getDiningCommonsCode));
return result;
}
/** Average review score for each dining commons grouped by month (used to draw a graph). */
@Operation(summary = "Average review score for each dining commons grouped by month")
@PreAuthorize("hasRole('ROLE_USER')")
@GetMapping("/commons/averages/overtime")
public List<CommonsAverageOverTime> commonsAveragesOverTime() {
log.info("statistics.commonsAveragesOverTime");
Map<String, double[]> buckets = new HashMap<>();
Map<String, String[]> labels = new HashMap<>();
for (Review r : approvedReviews()) {
MenuItem item = r.getItem();
if (item == null
|| item.getDiningCommonsCode() == null
|| r.getItemsStars() == null
|| r.getDateItemServed() == null) {
continue;
}
String code = item.getDiningCommonsCode();
String period = r.getDateItemServed().format(MONTH_FORMATTER);
String key = code + "|" + period;
double[] acc = buckets.computeIfAbsent(key, k -> new double[2]);
acc[0] += r.getItemsStars();
acc[1] += 1.0;
labels.computeIfAbsent(key, k -> new String[] {code, period});
}
List<CommonsAverageOverTime> result = new ArrayList<>();
for (Map.Entry<String, double[]> entry : buckets.entrySet()) {
String[] label = labels.get(entry.getKey());
double[] acc = entry.getValue();
long count = (long) acc[1];
result.add(
CommonsAverageOverTime.builder()
.diningCommonsCode(label[0])
.period(label[1])
.averageStars(acc[0] / acc[1])
.reviewCount(count)
.build());
}
result.sort(
Comparator.comparing(CommonsAverageOverTime::getDiningCommonsCode)
.thenComparing(CommonsAverageOverTime::getPeriod));
return result;
}
/** Average review score for each meal slot at the supplied dining commons. */
@Operation(summary = "Average review score grouped by meal for a single dining commons")
@PreAuthorize("hasRole('ROLE_USER')")
@GetMapping("/commons/{code}/meals/averages")
public List<MealAverage> commonsMealAverages(
@Parameter(name = "code", description = "dining commons code, e.g. 'carrillo'")
@PathVariable("code")
String code) {
log.info("statistics.commonsMealAverages code={}", code);
Map<String, double[]> byMeal = new HashMap<>();
for (Review r : approvedReviews()) {
MenuItem item = r.getItem();
if (item == null
|| item.getMealCode() == null
|| r.getItemsStars() == null
|| !code.equals(item.getDiningCommonsCode())) {
continue;
}
double[] acc = byMeal.computeIfAbsent(item.getMealCode(), k -> new double[2]);
acc[0] += r.getItemsStars();
acc[1] += 1.0;
}
List<MealAverage> result = new ArrayList<>();
for (Map.Entry<String, double[]> entry : byMeal.entrySet()) {
double[] acc = entry.getValue();
long count = (long) acc[1];
result.add(
MealAverage.builder()
.diningCommonsCode(code)
.mealCode(entry.getKey())
.averageStars(acc[0] / acc[1])
.reviewCount(count)
.build());
}
result.sort(Comparator.comparing(MealAverage::getMealCode));
return result;
}
}