hiling_go/api/location.go
2026-06-14 05:52:04 +03:00

651 lines
20 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"
"regexp"
"strings"
"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 businessHourEntry struct {
Day string `json:"day"`
Open string `json:"open,omitempty"`
Close string `json:"close,omitempty"`
Closed bool `json:"closed,omitempty"`
}
var validBusinessHourDays = map[string]struct{}{
"Sunday": {},
"Monday": {},
"Tuesday": {},
"Wednesday": {},
"Thursday": {},
"Friday": {},
"Saturday": {},
}
var businessHourTimeRe = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`)
func parseBusinessHours(raw string) (pgtype.Text, error) {
if raw == "" {
return pgtype.Text{Valid: false}, nil
}
var entries []businessHourEntry
dec := json.NewDecoder(strings.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&entries); err != nil {
return pgtype.Text{}, fmt.Errorf("business_hours: invalid JSON: %w", err)
}
seen := make(map[string]struct{}, len(entries))
for i, e := range entries {
if _, ok := validBusinessHourDays[e.Day]; !ok {
return pgtype.Text{}, fmt.Errorf("business_hours[%d]: invalid day %q", i, e.Day)
}
if _, dup := seen[e.Day]; dup {
return pgtype.Text{}, fmt.Errorf("business_hours[%d]: duplicate day %q", i, e.Day)
}
seen[e.Day] = struct{}{}
if e.Closed {
entries[i].Open = ""
entries[i].Close = ""
continue
}
if !businessHourTimeRe.MatchString(e.Open) {
return pgtype.Text{}, fmt.Errorf("business_hours[%d]: invalid open time %q (want HH:MM)", i, e.Open)
}
if !businessHourTimeRe.MatchString(e.Close) {
return pgtype.Text{}, fmt.Errorf("business_hours[%d]: invalid close time %q (want HH:MM)", i, e.Close)
}
}
canon, err := json.Marshal(entries)
if err != nil {
return pgtype.Text{}, err
}
return pgtype.Text{Valid: true, String: string(canon)}, nil
}
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"`
BusinessHours string `form:"business_hours"`
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 business_hours formData string false "Business hours JSON: [{\"day\":\"Monday\",\"open\":\"07:00\",\"close\":\"21:00\",\"closed\":false}, ...]"
// @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}
}
}
businessHours, err := parseBusinessHours(req.BusinessHours)
if err != nil {
ctx.JSON(http.StatusBadRequest, ErrorResponse(err, "Invalid business_hours"))
return
}
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,
BusinessHours: businessHours,
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)
}
type recordLocationVisitReq struct {
ID int32 `uri:"location_id" binding:"required,number"`
}
// @Summary Record a page visit for a location
// @Description Increments today's visit bucket. A single client (IP) is rate-limited to one visit per location per 30 minutes via Redis to deter trivial F5 inflation.
// @Tags locations
// @Produce json
// @Param location_id path int true "Location ID"
// @Success 204
// @Failure 400 {object} map[string]any
// @Failure 500 {object} map[string]any
// @Router /location/{location_id}/visit [post]
func (server *Server) recordLocationVisit(ctx *gin.Context) {
var req recordLocationVisitReq
if err := ctx.ShouldBindUri(&req); err != nil {
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
return
}
dedupKey := fmt.Sprintf("visit:%s:%d", ctx.ClientIP(), req.ID)
added, err := server.Redis.SetNX(ctx, dedupKey, "1", 30*time.Minute).Result()
if err == nil && !added {
ctx.Writer.WriteHeader(http.StatusNoContent)
return
}
if err := server.Store.RecordLocationVisit(ctx, req.ID); err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while recording the visit"))
return
}
ctx.Writer.WriteHeader(http.StatusNoContent)
}
type getTrendingLocationsReq struct {
Window string `form:"window" binding:"required"`
Page int32 `form:"page" binding:"required,min=1"`
PageSize int32 `form:"page_size" binding:"required,min=1,max=50"`
}
// @Summary Trending locations
// @Description Ranks approved, non-deleted locations by total page-visits in the trailing window.
// @Tags locations
// @Produce json
// @Param window query string true "Window: week | month | 3month | semester | year"
// @Param page query int true "Page (min 1)"
// @Param page_size query int true "Page size (1-50)"
// @Success 200 {array} map[string]any
// @Failure 400 {object} map[string]any
// @Failure 500 {object} map[string]any
// @Router /locations/trending [get]
func (server *Server) getTrendingLocations(ctx *gin.Context) {
var req getTrendingLocationsReq
if err := ctx.ShouldBindQuery(&req); err != nil {
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
return
}
window, ok := db.ParseTrendingWindow(req.Window)
if !ok {
ctx.JSON(http.StatusBadRequest, ErrorResponse(
fmt.Errorf("invalid window %q", req.Window),
"window must be one of: week, month, 3month, semester, year",
))
return
}
cacheKey := fmt.Sprintf("cache:trending:%s:%d:%d", window, req.Page, req.PageSize)
if cached, err := server.Redis.Get(ctx, cacheKey).Result(); err == nil {
ctx.Data(http.StatusOK, "application/json", []byte(cached))
return
}
rows, err := server.Store.GetTrendingLocations(ctx, db.GetTrendingLocationsParams{
Window: window,
Limit: req.PageSize,
Offset: (req.Page - 1) * req.PageSize,
})
if err != nil {
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while computing trending locations"))
return
}
if data, err := json.Marshal(rows); err == nil {
server.Redis.Set(ctx, cacheKey, data, 5*time.Minute)
}
ctx.JSON(http.StatusOK, rows)
}