hiling_go/api/location.go
2026-06-03 13:46:26 +03:00

492 lines
15 KiB
Go
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
db "git.nochill.in/nochill/hiling_go/db/repository"
"git.nochill.in/nochill/hiling_go/util"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/meilisearch/meilisearch-go"
ysqlc "github.com/yiplee/sqlc"
)
type createLocationReq struct {
Address string `form:"address" binding:"required"`
Name string `form:"name" binding:"required"`
SubmittedBy int32 `form:"submitted_by" binding:"required,number"`
RegencyID int16 `form:"regency_id" binding:"required,number"`
LocationType string `form:"location_type" binding:"required"`
Amenities string `form:"amenities"`
RestaurantMenu string `form:"restaurant_menu"`
GoogleMapsLink string `form:"google_maps_link"`
}
// @Summary Create a location
// @Tags locations
// @Accept mpfd
// @Produce json
// @Param address formData string true "Address"
// @Param name formData string true "Name"
// @Param submitted_by formData int true "Submitted by user ID"
// @Param regency_id formData int true "Regency ID"
// @Param location_type formData string true "Location type"
// @Param amenities formData string false "Amenities (comma-separated)"
// @Param google_maps_link formData string false "Google Maps link"
// @Param thumbnail formData file false "Thumbnail image"
// @Success 200
// @Failure 400 {object} map[string]any
// @Router /locations [post]
func (server *Server) createLocation(ctx *gin.Context) {
var req createLocationReq
var imgPath string
var tempImg []db.CreateImageParams
if err := ctx.Bind(&req); err != nil {
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
return
}
form, _ := ctx.MultipartForm()
thumbnails := form.File["thumbnail"]
if len(thumbnails) > 0 {
for _, img := range thumbnails {
fileExt := filepath.Ext(img.Filename)
now := time.Now()
dir := fmt.Sprintf("public/upload/images/locations/%s/thumbnail", req.Name)
osFilename := fmt.Sprintf("%s%s%s", util.RandomString(5), fmt.Sprintf("%v", now.Unix()), fileExt)
imgPath = fmt.Sprintf("%s/%s", dir, osFilename)
if _, err := os.Stat(dir); os.IsNotExist(err) {
os.Mkdir(dir, 0775)
}
if err := ctx.SaveUploadedFile(img, imgPath); err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Error while try to save thumbnail image"))
return
}
}
var amenities pgtype.Text
if req.Amenities != "" {
var parsed []map[string][]string
if err := json.Unmarshal([]byte(req.Amenities), &parsed); err == nil {
amenities = pgtype.Text{Valid: true, String: req.Amenities}
}
}
var restaurantMenu pgtype.Text
if req.RestaurantMenu != "" {
var parsed []map[string]any
if err := json.Unmarshal([]byte(req.RestaurantMenu), &parsed); err == nil {
restaurantMenu = pgtype.Text{Valid: true, String: req.RestaurantMenu}
}
}
arg := db.CreateLocationTxParams{
Address: req.Address,
Name: req.Name,
LocationType: db.LocationType(req.LocationType),
SubmittedBy: req.SubmittedBy,
RegencyID: req.RegencyID,
IsDeleted: false,
ApprovedBy: pgtype.Int4{Int32: 0, Valid: false},
GoogleMapsLink: pgtype.Text{Valid: len(req.GoogleMapsLink) > 0, String: req.GoogleMapsLink},
Amenities: amenities,
RestaurantMenu: restaurantMenu,
Thumbnail: tempImg,
}
err := server.Store.CreateLocationTx(ctx, arg)
if err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while try to save Location"))
return
}
} else {
ctx.JSON(http.StatusBadRequest, ErrorResponse(fmt.Errorf("thumbnail is required"), ""))
return
}
ctx.Writer.WriteHeader(http.StatusNoContent)
}
type getListLocationsReq struct {
Page int32 `form:"page" binding:"required,min=1"`
PageSize int32 `form:"page_size" binding:"required,min=5"`
}
// @Summary List locations
// @Tags locations
// @Produce json
// @Param page query int true "Page number (min 1)"
// @Param page_size query int true "Page size (min 5)"
// @Success 200 {array} map[string]any
// @Failure 400 {object} map[string]any
// @Router /locations [get]
func (server *Server) getListLocations(ctx *gin.Context) {
var req getListLocationsReq
if err := ctx.ShouldBindQuery(&req); err != nil {
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
return
}
locations, err := server.Store.GetListLocations(ysqlc.Build(ctx, func(builder *ysqlc.Builder) {
builder.Limit(int(req.PageSize))
builder.Offset(int(req.Page-1) * int(req.PageSize))
builder.Order("created_at ASC")
}))
if err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong"))
return
}
ctx.JSON(http.StatusOK, locations)
}
type getTopListLocationsReq struct {
Page int32 `form:"page" binding:"required,min=1"`
PageSize int32 `form:"page_size" binding:"required,min=5"`
OrderBy int16 `form:"order_by" binding:"numeric,min=1,max=3"`
RegionType int32 `form:"region_type" binding:"numeric,min=0,max=7"`
}
// @Summary Top-rated locations
// @Tags locations
// @Produce json
// @Param page query int true "Page number (min 1)"
// @Param page_size query int true "Page size (min 5)"
// @Param order_by query int false "Sort: 1=overall, 2=critics, 3=users"
// @Param region_type query int false "Region type filter (07)"
// @Success 200 {array} map[string]any
// @Failure 400 {object} map[string]any
// @Router /locations/top-ratings [get]
func (server *Server) getTopListLocations(ctx *gin.Context) {
var req getTopListLocationsReq
var orderby string
if err := ctx.ShouldBindQuery(&req); err != nil {
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
return
}
cacheKey := fmt.Sprintf("cache:top_locations:%d:%d:%d:%d",
req.Page, req.PageSize, req.OrderBy, req.RegionType)
if cached, err := server.Redis.Get(ctx, cacheKey).Result(); err == nil {
ctx.Data(http.StatusOK, "application/json", []byte(cached))
return
}
switch req.OrderBy {
case 1:
orderby = "iq2.avg_bayes"
case 2:
orderby = "iq2.critic_bayes"
case 3:
orderby = "iq2.user_bayes"
}
arg := db.GetTopListLocationsParams{
Limit: req.PageSize,
Offset: (req.Page - 1) * req.PageSize,
OrderBy: orderby,
RegionType: pgtype.Int4{Valid: req.RegionType > 0, Int32: req.RegionType},
}
locations, err := server.Store.GetTopListLocations(ctx, arg)
if err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong"))
return
}
if data, err := json.Marshal(locations); err == nil {
server.Redis.Set(ctx, cacheKey, data, 5*time.Minute)
}
ctx.JSON(http.StatusOK, locations)
// str, err := ctx.Cookie("kek123429")
// if err != nil {
// ctx.JSON(http.StatusUnauthorized, ErrorResponse(err, ""))
// }
// ctx.JSON(http.StatusOK, gin.H{
// "str": str,
// "res": locations,
// })
}
type getListRecentLocationsWithRatingsReq struct {
Page int32 `form:"page_size" binding:"required,min=1"`
LocationType string `form:"location_type"`
Region string `form:"regions"`
}
// @Summary Recent locations with ratings
// @Tags locations
// @Produce json
// @Param page_size query int true "Number of results (min 1)"
// @Param location_type query string false "Filter by location type"
// @Param regions query string false "Filter by region"
// @Success 200 {array} map[string]any
// @Failure 400 {object} map[string]any
// @Router /locations/recent [get]
func (server *Server) getListRecentLocationsWithRatings(ctx *gin.Context) {
var req getListRecentLocationsWithRatingsReq
if err := ctx.ShouldBindQuery(&req); err != nil {
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
return
}
locations, err := server.Store.GetListRecentLocationsWithRatings(ctx, db.GetListRecentLocationsParams{
Limit: req.Page,
Regions: req.Region,
LocationTypes: req.LocationType,
})
if err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong"))
return
}
ctx.JSON(http.StatusOK, locations)
}
type getLocationReq struct {
ID int32 `uri:"location_id" binding:"required"`
Review string `form:"review"` // "user", "critics", or "" (default = both)
Page int8 `form:"page"` // default 1
PageSize int8 `form:"page_size"` // default 5
}
// @Summary Get location detail
// @Tags locations
// @Produce json
// @Param location_id path int true "Location ID"
// @Param review query string false "Review type: 'user', 'critics', or both (default)"
// @Param page query int false "Page (default 1)"
// @Param page_size query int false "Page size (default 5)"
// @Success 200 {object} map[string]any
// @Failure 404 {object} map[string]any
// @Router /location/{location_id} [get]
func (server *Server) getLocation(ctx *gin.Context) {
var req getLocationReq
if err := ctx.ShouldBindUri(&req); err != nil {
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
return
}
ctx.ShouldBindQuery(&req)
location, err := server.Store.GetLocation(ctx, req.ID)
if err != nil {
if err == pgx.ErrNoRows {
ctx.JSON(http.StatusNotFound, ErrorResponse(err, ""))
return
}
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while retrieving location detail"))
return
}
tags, err := server.Store.GetLocationTag(ctx, req.ID)
if err != nil {
ctx.JSON(http.StatusBadRequest, ErrorResponse(err, "Something went wrong while retrieving location tags"))
return
}
type reviewWithImages struct {
db.GetListLocationReviewsRow
Images []db.GetImagesByReviewRow `json:"images"`
}
attachImages := func(reviews []db.GetListLocationReviewsRow) []reviewWithImages {
result := make([]reviewWithImages, 0, len(reviews))
for _, r := range reviews {
images, _ := server.Store.GetImagesByReview(ctx, r.ID)
if images == nil {
images = []db.GetImagesByReviewRow{}
}
result = append(result, reviewWithImages{r, images})
}
return result
}
fetchUsers := req.Review == "" || req.Review == "user"
fetchCritics := req.Review == "" || req.Review == "critics"
page := req.Page
if page < 1 {
page = 1
}
pageSize := req.PageSize
if pageSize < 1 {
pageSize = 5
}
offset := (page - 1) * pageSize
var users_reviews []db.GetListLocationReviewsRow
var critics_reviews []db.GetListLocationReviewsRow
if fetchUsers {
users_reviews, err = server.Store.GetListLocationReviews(ctx, db.GetListLocationReviewsParams{
LocationID: req.ID,
Limit: pageSize,
Offset: offset,
IsCritics: false,
})
if err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while try to receive user reviews for this location"))
return
}
}
if fetchCritics {
critics_reviews, err = server.Store.GetListLocationReviews(ctx, db.GetListLocationReviewsParams{
LocationID: req.ID,
Limit: pageSize,
Offset: offset,
IsCritics: true,
})
if err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while try to receive user reviews for this location"))
return
}
}
res := gin.H{
"tags": tags,
"detail": location,
"users_review": attachImages(users_reviews),
"critics_review": attachImages(critics_reviews),
}
ctx.JSON(http.StatusOK, res)
}
type getListLocationReviewsReq struct {
PageSize int8 `form:"page_size" binding:"required"`
Page int8 `form:"page" binding:"required,min=1"`
Type int8 `form:"type" binding:"required"` // 0 = all, 1 = critics,2 = users
LocationID int32 `form:"location_id" binding:"required"`
}
type getListLocationReviewsRes struct {
CriticsReviews []db.GetListLocationReviewsRow `json:"critics_reviews"`
UsersReviews []db.GetListLocationReviewsRow `json:"users_reviews"`
}
// @Summary List location reviews
// @Tags locations
// @Produce json
// @Param location_id query int true "Location ID"
// @Param page query int true "Page (min 1)"
// @Param page_size query int true "Page size"
// @Param type query int true "Review type: 1=critics, 2=users, 0=all"
// @Success 200 {object} map[string]any
// @Failure 400 {object} map[string]any
// @Router /location/reviews [get]
func (server *Server) getListLocationReviews(ctx *gin.Context) {
var req getListLocationReviewsReq
var res getListLocationReviewsRes
if err := ctx.ShouldBindQuery(&req); err != nil {
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
return
}
if req.Type-1 == 1 || req.Type-1 == 0 {
arg := db.GetListLocationReviewsParams{
LocationID: req.LocationID,
Offset: (req.Page - 1) * req.PageSize,
Limit: req.PageSize,
IsCritics: true,
}
reviews, err := server.Store.GetListLocationReviews(ctx, arg)
if err != nil {
if err == pgx.ErrNoRows {
ctx.JSON(http.StatusNotFound, ErrorResponse(err, "There's no critics reviews for this location yet"))
return
}
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while try to get critics reviews for this location"))
return
}
res.CriticsReviews = reviews
}
if req.Type-1 == 2 || req.Type-1 == 0 {
arg := db.GetListLocationReviewsParams{
LocationID: req.LocationID,
Limit: req.PageSize,
Offset: (req.Page - 1) * req.PageSize,
IsCritics: false,
}
reviews, err := server.Store.GetListLocationReviews(ctx, arg)
if err != nil {
if err == pgx.ErrNoRows {
ctx.JSON(http.StatusNotFound, ErrorResponse(err, "There's no critics reviews for this location yet"))
return
}
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while try to get critics reviews for this location"))
return
}
res.UsersReviews = reviews
}
ctx.JSON(http.StatusOK, res)
}
type searchLocationsParams struct {
Name string `form:"name"`
Filter string `form:"filter"`
}
// @Summary Search locations
// @Tags locations
// @Produce json
// @Param name query string false "Search query"
// @Param filter query string false "Meilisearch filter expression"
// @Success 200 {array} map[string]any
// @Failure 500 {object} map[string]any
// @Router /locations/search [get]
func (server *Server) searchLocations(ctx *gin.Context) {
var req searchLocationsParams
if err := ctx.ShouldBindQuery(&req); err != nil {
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
return
}
searchRequest := meilisearch.SearchRequest{
Limit: 7,
Offset: 0,
Filter: req.Filter,
}
searchRes, err := server.MeilisearchClient.Index("locations").Search(req.Name, &searchRequest)
if err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, fmt.Sprintf("Something went wrong while try to search locations %s", req.Name)))
return
}
ctx.JSON(http.StatusOK, searchRes.Hits)
}