hiling_go/api/reviews.go
2026-06-16 19:38:23 +03:00

342 lines
11 KiB
Go
Executable File

package api
import (
"fmt"
"log"
"net/http"
"path/filepath"
"strconv"
"time"
db "git.nochill.in/nochill/hiling_go/db/repository"
"git.nochill.in/nochill/hiling_go/util"
"git.nochill.in/nochill/hiling_go/util/token"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
type createReviewReq struct {
SubmittedBy int8 `json:"submitted_by" binding:"required"`
Comments string `json:"comments" binding:"required"`
Score int8 `json:"score" binding:"numeric,max=100"`
IsFromCritic *bool `json:"is_from_critic"`
IsHided *bool `json:"is_hided"`
LocationId int32 `json:"location_id" binding:"required"`
Title string `json:"title"`
}
// @Summary Create a review
// @Tags reviews
// @Accept json
// @Produce json
// @Security CookieAuth
// @Param body body createReviewReq true "Review payload"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any
// @Failure 409 {object} map[string]any
// @Router /review/location [post]
func (server *Server) createReview(ctx *gin.Context) {
var req createReviewReq
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
return
}
checkArg := db.CheckIfReviewExistsParams{
LocationID: req.LocationId,
SubmittedBy: int32(req.SubmittedBy),
}
reviewExist, err := server.Store.CheckIfReviewExists(ctx, checkArg)
if err != nil {
ctx.JSON(http.StatusInternalServerError, "Something went wrong while try to check review")
return
}
if reviewExist != 0 {
ctx.JSON(http.StatusConflict, "User review already exist")
return
}
arg := db.CreateReviewParams{
SubmittedBy: int32(req.SubmittedBy),
Comments: req.Comments,
Score: int16(req.Score),
IsFromCritic: *req.IsFromCritic,
IsHided: *req.IsHided,
LocationID: req.LocationId,
Title: pgtype.Text{String: req.Title, Valid: req.Title != ""},
}
review, err := server.Store.CreateReview(ctx, arg)
if err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while try to save review"))
return
}
ctx.JSON(http.StatusOK, review)
}
// @Summary Upload images for a review
// @Tags reviews
// @Accept mpfd
// @Produce json
// @Security CookieAuth
// @Param review_id formData int true "Review ID"
// @Param images formData file true "Image files"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any
// @Router /review/location/images [post]
func (server *Server) uploadReviewImages(ctx *gin.Context) {
reviewIdStr := ctx.PostForm("review_id")
reviewId, err := strconv.Atoi(reviewIdStr)
if err != nil || reviewId <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"message": "Invalid review_id"})
return
}
authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload)
form, err := ctx.MultipartForm()
if err != nil {
ctx.JSON(http.StatusBadRequest, ErrorResponse(err, "Failed to parse multipart form"))
return
}
files := form.File["images"]
if len(files) == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"message": "No images provided"})
return
}
var params []db.CreateImageParams
for _, fileHeader := range files {
fileExt := filepath.Ext(fileHeader.Filename)
key := fmt.Sprintf("reviews/%d/%s%d%s", reviewId, util.RandomString(5), time.Now().UnixNano(), fileExt)
f, err := fileHeader.Open()
if err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Failed to open image"))
return
}
defer f.Close()
contentType := fileHeader.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
publicURL, err := server.R2.UploadFile(ctx.Request.Context(), key, f, contentType)
if err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Failed to upload image to storage"))
return
}
params = append(params, db.CreateImageParams{
ImageUrl: publicURL,
UploadedBy: int32(authPayload.UserID),
ImageType: "reviews",
ImageOf: int32(reviewId),
})
}
if err := server.Store.CreateImage(ctx, params); err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Failed to save image records"))
return
}
ctx.JSON(http.StatusOK, gin.H{"uploaded": len(params)})
}
type getReviewReq struct {
LocationID int32 `uri:"location_id" binding:"required"`
ReviewID int32 `uri:"review_id" binding:"required"`
}
type getReviewRes struct {
db.GetReviewByLocationAndId
Images []db.GetImagesByReviewRow `json:"images"`
}
// @Summary Get a single review
// @Tags reviews
// @Produce json
// @Param location_id path int true "Location ID"
// @Param review_id path int true "Review ID"
// @Success 200 {object} map[string]any
// @Failure 404 {object} map[string]any
// @Router /location/{location_id}/review/{review_id} [get]
func (server *Server) getReview(ctx *gin.Context) {
var req getReviewReq
if err := ctx.ShouldBindUri(&req); err != nil {
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
return
}
arg := db.GetReviewByLocationAndIdParams{
ID: req.ReviewID,
LocationID: req.LocationID,
}
review, err := server.Store.GetReviewByLocationAndId(ctx, arg)
if err != nil {
if err == pgx.ErrNoRows {
ctx.JSON(http.StatusNotFound, ErrorResponse(err, "Review not found"))
return
}
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while trying to retrieve review"))
return
}
images, err := server.Store.GetImagesByReview(ctx, review.ID)
if err != nil {
images = []db.GetImagesByReviewRow{}
}
ctx.JSON(http.StatusOK, getReviewRes{
GetReviewByLocationAndId: review,
Images: images,
})
}
type getUserReviewByLocationReq struct {
LocationID int32 `uri:"location_id" binding:"required"`
}
type userReviewWithImagesResponse struct {
db.GetUserReviewByLocationRow
Images []db.GetImagesByReviewRow `json:"images"`
}
// @Summary Get current user's review for a location
// @Tags reviews
// @Produce json
// @Security CookieAuth
// @Param location_id path int true "Location ID"
// @Success 200 {object} map[string]any
// @Failure 404 {object} map[string]any
// @Router /user/review/location/{location_id} [get]
func (server *Server) getUserReviewByLocation(ctx *gin.Context) {
var req getUserReviewByLocationReq
if err := ctx.ShouldBindUri(&req); err != nil {
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
return
}
authPayload := ctx.MustGet(authorizationPayloadKey).(*token.Payload)
log.Println(authPayload)
arg := db.GetUserReviewByLocationParams{
SubmittedBy: int32(authPayload.UserID),
LocationID: req.LocationID,
}
review, err := server.Store.GetUserReviewByLocation(ctx, arg)
if err != nil {
if err == pgx.ErrNoRows {
ctx.JSON(http.StatusNotFound, ErrorResponse(err, "Review not found"))
return
}
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while try to recevie current user review"))
return
}
images, err := server.Store.GetImagesByReview(ctx, review.ID)
if err != nil {
images = []db.GetImagesByReviewRow{}
}
ctx.JSON(http.StatusOK, userReviewWithImagesResponse{
GetUserReviewByLocationRow: review,
Images: images,
})
}
type getLocationReviewGradesReq struct {
LocationID int32 `uri:"location_id" binding:"required"`
}
type locationReviewGradesGroup struct {
AvgTasteGrade *float64 `json:"avg_taste_grade"`
TasteGradeCount int64 `json:"taste_grade_count"`
AvgComfortGrade *float64 `json:"avg_comfort_grade"`
ComfortGradeCount int64 `json:"comfort_grade_count"`
AvgCleanlinessGrade *float64 `json:"avg_cleanliness_grade"`
CleanlinessGradeCount int64 `json:"cleanliness_grade_count"`
AvgServiceGrade *float64 `json:"avg_service_grade"`
ServiceGradeCount int64 `json:"service_grade_count"`
AvgFacilitiesGrade *float64 `json:"avg_facilities_grade"`
FacilitiesGradeCount int64 `json:"facilities_grade_count"`
}
type getLocationReviewGradesRes struct {
CriticsGrades locationReviewGradesGroup `json:"critics_grades"`
UsersGrades locationReviewGradesGroup `json:"users_grades"`
}
func nullableFloat(v pgtype.Float8) *float64 {
if !v.Valid {
return nil
}
return &v.Float64
}
// @Summary Get aggregated review grades for a location
// @Tags reviews
// @Produce json
// @Param location_id path int true "Location ID"
// @Success 200 {object} getLocationReviewGradesRes
// @Failure 400 {object} map[string]any
// @Failure 500 {object} map[string]any
// @Router /location/{location_id}/reviews/grades [get]
func (server *Server) getLocationReviewGrades(ctx *gin.Context) {
var req getLocationReviewGradesReq
if err := ctx.ShouldBindUri(&req); err != nil {
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
return
}
grades, err := server.Store.GetLocationReviewGrades(ctx, req.LocationID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while trying to retrieve review grades"))
return
}
ctx.JSON(http.StatusOK, getLocationReviewGradesRes{
CriticsGrades: locationReviewGradesGroup{
AvgTasteGrade: nullableFloat(grades.CriticAvgTasteGrade),
TasteGradeCount: grades.CriticTasteGradeCount,
AvgComfortGrade: nullableFloat(grades.CriticAvgComfortGrade),
ComfortGradeCount: grades.CriticComfortGradeCount,
AvgCleanlinessGrade: nullableFloat(grades.CriticAvgCleanlinessGrade),
CleanlinessGradeCount: grades.CriticCleanlinessGradeCount,
AvgServiceGrade: nullableFloat(grades.CriticAvgServiceGrade),
ServiceGradeCount: grades.CriticServiceGradeCount,
AvgFacilitiesGrade: nullableFloat(grades.CriticAvgFacilitiesGrade),
FacilitiesGradeCount: grades.CriticFacilitiesGradeCount,
},
UsersGrades: locationReviewGradesGroup{
AvgTasteGrade: nullableFloat(grades.UserAvgTasteGrade),
TasteGradeCount: grades.UserTasteGradeCount,
AvgComfortGrade: nullableFloat(grades.UserAvgComfortGrade),
ComfortGradeCount: grades.UserComfortGradeCount,
AvgCleanlinessGrade: nullableFloat(grades.UserAvgCleanlinessGrade),
CleanlinessGradeCount: grades.UserCleanlinessGradeCount,
AvgServiceGrade: nullableFloat(grades.UserAvgServiceGrade),
ServiceGradeCount: grades.UserServiceGradeCount,
AvgFacilitiesGrade: nullableFloat(grades.UserAvgFacilitiesGrade),
FacilitiesGradeCount: grades.UserFacilitiesGradeCount,
},
})
}