651 lines
20 KiB
Go
Executable File
651 lines
20 KiB
Go
Executable File
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 (0–7)"
|
||
// @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)
|
||
}
|