package controllers import ( "net/http" "clapclap/internal/apperr" "clapclap/internal/dtos" "clapclap/internal/ginctx" "clapclap/internal/models" "clapclap/internal/repositories" "clapclap/internal/utils" "clapclap/internal/uuid" "github.com/gin-gonic/gin" ) // --- HANDLERS --- // UpsertRating godoc // @Summary Create or Update a Rating // @Description Upserts (inserts or updates) a rating from a user for a specific track. // @Tags ratings // @Accept json // @Produce json // @Param rating body dtos.UpsertRatingRequest true "Rating Data" // @Success 200 {object} dtos.RatingResponse // @Failure 400 {object} swaggError400 // @Failure 401 {object} swaggError401 // @Failure 422 {object} swaggError422 // @Failure 500 {object} swaggError500 // @Security BearerAuth // @Router /ratings [post] func UpsertRating(c *gin.Context) { userID, _ := ginctx.GetUUID(c, "userID") req, _ := ginctx.GetDto[dtos.UpsertRatingRequest](c) repo, _ := ginctx.GetRepo(c, "repo") trackUUID, err := uuid.Parse(req.TrackID) if err != nil { apperr.AbortWithError(c, apperr.NewBadRequestError(err, "Invalid Track ID format")) return } ratingModel := models.Rating{ Value: req.Value, TrackID: trackUUID, AuthorID: userID, } var finalAvg float64 var finalCount int // Execute business logic inside a safe Database Transaction Wrapper err = repo.RunInTx(func(txRepo *repositories.Repo) error { // 1. Check for an existing rating to determine if this is an update existing, err := txRepo.GetExistingRating(ratingModel.AuthorID, ratingModel.TrackID) if err != nil { return err } isUpdate := existing != nil oldValue := 0 if isUpdate { oldValue = existing.Value } // 2. Perform the Upsert if err := txRepo.Upsert(&ratingModel); err != nil { return err } // 3. Fetch current track stats currAvg, currCount, err := txRepo.GetTrackStats(ratingModel.TrackID) if err != nil { return err } // 4. Calculate new stats purely in memory finalAvg, finalCount = calculateNewStats(currAvg, currCount, ratingModel.Value, oldValue, isUpdate, false) // 5. Persist the new stats to the track return txRepo.UpdateTrackStats(ratingModel.TrackID, finalAvg, finalCount) }) if err != nil { apperr.AbortWithError(c, apperr.NewDatabaseError(err, "Failed to save rating")) return } c.JSON(http.StatusOK, dtos.RatingResponse{ Message: "Rating saved successfully", ID: ratingModel.ID.String(), Count: finalCount, Value: finalAvg, }) } // GetRatingsByTrack godoc // @Summary Get ratings for a track // @Description Retrieves all user ratings associated with a specific track UUID // @Tags ratings // @Produce json // @Param trackID path string true "Track UUID" // @Param preload query string false "Relation to preload" Enums(Author, Track) // @Success 200 {array} models.Rating // @Failure 500 {object} swaggError500 // @Router /ratings/track/{trackID} [get] func GetRatingsByTrack(c *gin.Context) { repo, _ := ginctx.GetRepo(c, "repo") trackID := c.Param("trackID") preloads := utils.GetReqPreloads(c.Request, utils.GetRatingPreloads()) ratings, err := repo.GetByTrackID(trackID, preloads) if err != nil { apperr.AbortWithError(c, apperr.NewDatabaseError(err, "Failed to fetch ratings")) return } c.JSON(http.StatusOK, ratings) } // DeleteRating godoc // @Summary Delete a rating // @Description Deletes a rating from the database using its UUID and returns the recalculated track statistics. // @Tags ratings // @Produce json // @Param id path string true "Rating UUID" // @Success 200 {object} dtos.RatingResponse // @Failure 401 {object} swaggError401 // @Failure 403 {object} swaggError403 // @Failure 404 {object} swaggError404 // @Failure 500 {object} swaggError500 // @Security BearerAuth // @Router /ratings/{id} [delete] func DeleteRating(c *gin.Context) { userID, _ := ginctx.GetUUID(c, "userID") repo, _ := ginctx.GetRepo(c, "repo") id := c.Param("id") existingRating, err := repo.GetByID(id, nil) if err != nil { apperr.AbortWithError(c, apperr.NewDatabaseError(err, "Rating not found")) return } if existingRating.AuthorID != userID { apperr.AbortWithError(c, apperr.NewForbiddenError(nil, "You can only delete your own ratings")) return } uid, err := uuid.Parse(id) if err != nil { apperr.AbortWithError(c, apperr.NewBadRequestError(err, "Invalid ID format")) return } var finalAvg float64 var finalCount int // 3. Execute Deletion and Stat Recalculation inside the Transaction Wrapper err = repo.RunInTx(func(txRepo *repositories.Repo) error { // Delete the rating and get the deleted data deletedRating, err := txRepo.Delete(uid) if err != nil { return err } // Fetch current track stats currAvg, currCount, err := txRepo.GetTrackStats(deletedRating.TrackID) if err != nil { return err } // Calculate new stats purely in memory finalAvg, finalCount = calculateNewStats(currAvg, currCount, 0, deletedRating.Value, false, true) // Persist the new stats return txRepo.UpdateTrackStats(deletedRating.TrackID, finalAvg, finalCount) }) if err != nil { apperr.AbortWithError(c, apperr.NewDatabaseError(err, "Failed to delete rating")) return } c.JSON(http.StatusOK, dtos.RatingResponse{ Message: "Rating deleted successfully", Count: finalCount, Value: finalAvg, // The new average rating }) } // --- LOGIC / MATH --- // calculateNewStats is a pure function that calculates the new average and count. func calculateNewStats(currentAvg float64, currentCount, newValue, oldValue int, isUpdate, isDelete bool) (float64, int) { if isDelete { if currentCount <= 1 { return 0, 0 } // Formula: (OldAvg * OldCount - DeletedRating) / (OldCount - 1) newAvg := ((currentAvg * float64(currentCount)) - float64(oldValue)) / float64(currentCount-1) return newAvg, currentCount - 1 } if isUpdate { if currentCount == 0 { // Fallback just in case data is completely out of sync return float64(newValue), 1 } // Formula: (OldAvg * OldCount - OldRating + NewRating) / OldCount newAvg := ((currentAvg * float64(currentCount)) - float64(oldValue) + float64(newValue)) / float64(currentCount) return newAvg, currentCount } // Insert Formula: (OldAvg * OldCount + NewRating) / (OldCount + 1) newAvg := ((currentAvg * float64(currentCount)) + float64(newValue)) / float64(currentCount+1) return newAvg, currentCount + 1 }