update
This commit is contained in:
parent
67eb0a5a66
commit
305beabacb
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,4 +1,6 @@
|
|||||||
*.env
|
*.env
|
||||||
data.ms
|
data.ms
|
||||||
tmp
|
tmp
|
||||||
public
|
public
|
||||||
|
**/.DS_Store
|
||||||
|
.idea
|
||||||
161
api/location.go
161
api/location.go
@ -6,6 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
db "git.nochill.in/nochill/hiling_go/db/repository"
|
db "git.nochill.in/nochill/hiling_go/db/repository"
|
||||||
@ -17,6 +19,63 @@ import (
|
|||||||
ysqlc "github.com/yiplee/sqlc"
|
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 {
|
type createLocationReq struct {
|
||||||
Address string `form:"address" binding:"required"`
|
Address string `form:"address" binding:"required"`
|
||||||
Name string `form:"name" binding:"required"`
|
Name string `form:"name" binding:"required"`
|
||||||
@ -25,6 +84,7 @@ type createLocationReq struct {
|
|||||||
LocationType string `form:"location_type" binding:"required"`
|
LocationType string `form:"location_type" binding:"required"`
|
||||||
Amenities string `form:"amenities"`
|
Amenities string `form:"amenities"`
|
||||||
RestaurantMenu string `form:"restaurant_menu"`
|
RestaurantMenu string `form:"restaurant_menu"`
|
||||||
|
BusinessHours string `form:"business_hours"`
|
||||||
GoogleMapsLink string `form:"google_maps_link"`
|
GoogleMapsLink string `form:"google_maps_link"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,6 +98,7 @@ type createLocationReq struct {
|
|||||||
// @Param regency_id formData int true "Regency ID"
|
// @Param regency_id formData int true "Regency ID"
|
||||||
// @Param location_type formData string true "Location type"
|
// @Param location_type formData string true "Location type"
|
||||||
// @Param amenities formData string false "Amenities (comma-separated)"
|
// @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 google_maps_link formData string false "Google Maps link"
|
||||||
// @Param thumbnail formData file false "Thumbnail image"
|
// @Param thumbnail formData file false "Thumbnail image"
|
||||||
// @Success 200
|
// @Success 200
|
||||||
@ -90,6 +151,12 @@ func (server *Server) createLocation(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
businessHours, err := parseBusinessHours(req.BusinessHours)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, ErrorResponse(err, "Invalid business_hours"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
arg := db.CreateLocationTxParams{
|
arg := db.CreateLocationTxParams{
|
||||||
Address: req.Address,
|
Address: req.Address,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
@ -101,10 +168,11 @@ func (server *Server) createLocation(ctx *gin.Context) {
|
|||||||
GoogleMapsLink: pgtype.Text{Valid: len(req.GoogleMapsLink) > 0, String: req.GoogleMapsLink},
|
GoogleMapsLink: pgtype.Text{Valid: len(req.GoogleMapsLink) > 0, String: req.GoogleMapsLink},
|
||||||
Amenities: amenities,
|
Amenities: amenities,
|
||||||
RestaurantMenu: restaurantMenu,
|
RestaurantMenu: restaurantMenu,
|
||||||
|
BusinessHours: businessHours,
|
||||||
Thumbnail: tempImg,
|
Thumbnail: tempImg,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := server.Store.CreateLocationTx(ctx, arg)
|
err = server.Store.CreateLocationTx(ctx, arg)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while try to save Location"))
|
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while try to save Location"))
|
||||||
@ -489,3 +557,94 @@ func (server *Server) searchLocations(ctx *gin.Context) {
|
|||||||
|
|
||||||
ctx.JSON(http.StatusOK, searchRes.Hits)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
45
api/menu_items.go
Normal file
45
api/menu_items.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
db "git.nochill.in/nochill/hiling_go/db/repository"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
type getMenuItemsReq struct {
|
||||||
|
LocationID *int32 `form:"location_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Get menu items
|
||||||
|
// @Tags menu
|
||||||
|
// @Produce json
|
||||||
|
// @Param location_id query int false "Filter by location ID (omit for all)"
|
||||||
|
// @Success 200 {array} db.GetMenuItemsRow
|
||||||
|
// @Failure 400 {object} map[string]any
|
||||||
|
// @Router /menu-items [get]
|
||||||
|
func (server *Server) getMenuItems(ctx *gin.Context) {
|
||||||
|
var req getMenuItemsReq
|
||||||
|
if err := ctx.ShouldBindQuery(&req); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
locationID := pgtype.Int4{Valid: false}
|
||||||
|
if req.LocationID != nil {
|
||||||
|
locationID = pgtype.Int4{Int32: *req.LocationID, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := server.Store.GetMenuItems(ctx, locationID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while retrieving menu items"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if items == nil {
|
||||||
|
items = []db.GetMenuItemsRow{}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, items)
|
||||||
|
}
|
||||||
@ -85,16 +85,21 @@ func (server *Server) getRoutes() {
|
|||||||
// LOCATION
|
// LOCATION
|
||||||
router.GET("/locations/recent", server.getListRecentLocationsWithRatings)
|
router.GET("/locations/recent", server.getListRecentLocationsWithRatings)
|
||||||
router.GET("/locations/top-ratings", server.getTopListLocations)
|
router.GET("/locations/top-ratings", server.getTopListLocations)
|
||||||
|
router.GET("/locations/trending", server.getTrendingLocations)
|
||||||
router.GET("/locations", server.getListLocations)
|
router.GET("/locations", server.getListLocations)
|
||||||
router.GET("/location/:location_id", server.getLocation)
|
router.GET("/location/:location_id", server.getLocation)
|
||||||
router.GET("/location/tags/:location_id", server.getTagsByLocation)
|
router.GET("/location/tags/:location_id", server.getTagsByLocation)
|
||||||
router.GET("/location/reviews", server.getListLocationReviews)
|
router.GET("/location/reviews", server.getListLocationReviews)
|
||||||
router.GET("/location/:location_id/review/:review_id", server.getReview)
|
router.GET("/location/:location_id/review/:review_id", server.getReview)
|
||||||
router.GET("/locations/search", server.searchLocations)
|
router.GET("/locations/search", server.searchLocations)
|
||||||
|
router.POST("/location/:location_id/visit", server.recordLocationVisit)
|
||||||
|
|
||||||
//IMAGES
|
//IMAGES
|
||||||
router.GET("/images/location", server.getAllImagesByLocation)
|
router.GET("/images/location", server.getAllImagesByLocation)
|
||||||
|
|
||||||
|
// MENU ITEMS
|
||||||
|
router.GET("/menu-items", server.getMenuItems)
|
||||||
|
|
||||||
// NEWS / EVENTS
|
// NEWS / EVENTS
|
||||||
router.GET("/news-events", server.GetNewsEventsList)
|
router.GET("/news-events", server.GetNewsEventsList)
|
||||||
|
|
||||||
|
|||||||
@ -154,7 +154,6 @@ func randomLocation() db.Location {
|
|||||||
Name: util.RandomString(10),
|
Name: util.RandomString(10),
|
||||||
GoogleMapsLink: pgtype.Text{Valid: true, String: util.RandomString(20)},
|
GoogleMapsLink: pgtype.Text{Valid: true, String: util.RandomString(20)},
|
||||||
SubmittedBy: 1,
|
SubmittedBy: 1,
|
||||||
TotalVisited: pgtype.Int4{Valid: true, Int32: int32(util.RandomInt(0, 32))},
|
|
||||||
Thumbnail: pgtype.Text{Valid: false, String: ""},
|
Thumbnail: pgtype.Text{Valid: false, String: ""},
|
||||||
RegencyID: 1305,
|
RegencyID: 1305,
|
||||||
IsDeleted: false,
|
IsDeleted: false,
|
||||||
|
|||||||
@ -56,4 +56,4 @@ CREATE TABLE menu_item_reviews (
|
|||||||
"updated_at" timestamp default(now()),
|
"updated_at" timestamp default(now()),
|
||||||
UNIQUE("menu_item_id", "submitted_by")
|
UNIQUE("menu_item_id", "submitted_by")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE locations DROP COLUMN IF EXISTS business_hours;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE locations ADD IF NOT EXISTS business_hours jsonb;
|
||||||
20
db/migrations/000016_rebuld_location_page_visits.down.sql
Normal file
20
db/migrations/000016_rebuld_location_page_visits.down.sql
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
-- Restore the original (unused) weekly-bucket schema and total_visited column.
|
||||||
|
DROP INDEX IF EXISTS idx_location_page_visits_day_count;
|
||||||
|
DROP INDEX IF EXISTS idx_location_page_visits_location_day;
|
||||||
|
DROP TABLE IF EXISTS location_page_visits;
|
||||||
|
|
||||||
|
CREATE TABLE location_page_visits (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
location_id INT NOT NULL REFERENCES locations(id),
|
||||||
|
week_key VARCHAR(10) NOT NULL,
|
||||||
|
visit_count BIGINT DEFAULT(0) NOT NULL,
|
||||||
|
is_deleted BOOLEAN,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
|
||||||
|
UNIQUE(location_id, week_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_location_page_visits_week_key_visit_count
|
||||||
|
ON location_page_visits(week_key, visit_count DESC);
|
||||||
|
|
||||||
|
ALTER TABLE locations ADD COLUMN IF NOT EXISTS total_visited integer;
|
||||||
30
db/migrations/000016_rebuld_location_page_visits.up.sql
Normal file
30
db/migrations/000016_rebuld_location_page_visits.up.sql
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
-- Replace the unfinished weekly-bucket page-visit tracker with a daily-bucket
|
||||||
|
-- design. Daily granularity supports every trending window we expose
|
||||||
|
-- (week / month / 3-month / semester / year) with exact day boundaries; the
|
||||||
|
-- previous weekly bucket would have produced ±7-day error on any window that
|
||||||
|
-- isn't a whole number of weeks (i.e. month/year).
|
||||||
|
--
|
||||||
|
-- Also drops the never-read `locations.total_visited` integer column. The
|
||||||
|
-- lifetime total is recoverable from SUM(visit_count) on this table.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_location_page_visits_week_key_visit_count;
|
||||||
|
DROP TABLE IF EXISTS location_page_visits;
|
||||||
|
|
||||||
|
CREATE TABLE location_page_visits (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
location_id INT NOT NULL REFERENCES locations(id),
|
||||||
|
day DATE NOT NULL,
|
||||||
|
visit_count BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(location_id, day)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Trending: WHERE day >= now()::date - N ORDER BY visit_count DESC
|
||||||
|
CREATE INDEX idx_location_page_visits_day_count
|
||||||
|
ON location_page_visits(day, visit_count DESC);
|
||||||
|
|
||||||
|
-- Per-location lookup (lifetime sum, "visits over time" charts, etc.)
|
||||||
|
CREATE INDEX idx_location_page_visits_location_day
|
||||||
|
ON location_page_visits(location_id, day);
|
||||||
|
|
||||||
|
ALTER TABLE locations DROP COLUMN IF EXISTS total_visited;
|
||||||
@ -323,6 +323,21 @@ func (mr *MockStoreMockRecorder) GetLocation(ctx, location_id any) *gomock.Call
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLocation", reflect.TypeOf((*MockStore)(nil).GetLocation), ctx, location_id)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLocation", reflect.TypeOf((*MockStore)(nil).GetLocation), ctx, location_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLocationLifetimeVisits mocks base method.
|
||||||
|
func (m *MockStore) GetLocationLifetimeVisits(ctx context.Context, locationID int32) (int64, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "GetLocationLifetimeVisits", ctx, locationID)
|
||||||
|
ret0, _ := ret[0].(int64)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocationLifetimeVisits indicates an expected call of GetLocationLifetimeVisits.
|
||||||
|
func (mr *MockStoreMockRecorder) GetLocationLifetimeVisits(ctx, locationID any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLocationLifetimeVisits", reflect.TypeOf((*MockStore)(nil).GetLocationLifetimeVisits), ctx, locationID)
|
||||||
|
}
|
||||||
|
|
||||||
// GetLocationTag mocks base method.
|
// GetLocationTag mocks base method.
|
||||||
func (m *MockStore) GetLocationTag(ctx context.Context, targetID int32) ([]string, error) {
|
func (m *MockStore) GetLocationTag(ctx context.Context, targetID int32) ([]string, error) {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
@ -338,6 +353,21 @@ func (mr *MockStoreMockRecorder) GetLocationTag(ctx, targetID any) *gomock.Call
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLocationTag", reflect.TypeOf((*MockStore)(nil).GetLocationTag), ctx, targetID)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLocationTag", reflect.TypeOf((*MockStore)(nil).GetLocationTag), ctx, targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMenuItems mocks base method.
|
||||||
|
func (m *MockStore) GetMenuItems(ctx context.Context, locationID pgtype.Int4) ([]db.GetMenuItemsRow, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "GetMenuItems", ctx, locationID)
|
||||||
|
ret0, _ := ret[0].([]db.GetMenuItemsRow)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMenuItems indicates an expected call of GetMenuItems.
|
||||||
|
func (mr *MockStoreMockRecorder) GetMenuItems(ctx, locationID any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMenuItems", reflect.TypeOf((*MockStore)(nil).GetMenuItems), ctx, locationID)
|
||||||
|
}
|
||||||
|
|
||||||
// GetNewsEventsList mocks base method.
|
// GetNewsEventsList mocks base method.
|
||||||
func (m *MockStore) GetNewsEventsList(ctx context.Context, arg db.GetNewsEventsListParams) ([]db.NewsEventRow, error) {
|
func (m *MockStore) GetNewsEventsList(ctx context.Context, arg db.GetNewsEventsListParams) ([]db.NewsEventRow, error) {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
@ -398,6 +428,21 @@ func (mr *MockStoreMockRecorder) GetTopListLocations(ctx, arg any) *gomock.Call
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTopListLocations", reflect.TypeOf((*MockStore)(nil).GetTopListLocations), ctx, arg)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTopListLocations", reflect.TypeOf((*MockStore)(nil).GetTopListLocations), ctx, arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTrendingLocations mocks base method.
|
||||||
|
func (m *MockStore) GetTrendingLocations(ctx context.Context, arg db.GetTrendingLocationsParams) ([]db.GetTrendingLocationsRow, error) {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "GetTrendingLocations", ctx, arg)
|
||||||
|
ret0, _ := ret[0].([]db.GetTrendingLocationsRow)
|
||||||
|
ret1, _ := ret[1].(error)
|
||||||
|
return ret0, ret1
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrendingLocations indicates an expected call of GetTrendingLocations.
|
||||||
|
func (mr *MockStoreMockRecorder) GetTrendingLocations(ctx, arg any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTrendingLocations", reflect.TypeOf((*MockStore)(nil).GetTrendingLocations), ctx, arg)
|
||||||
|
}
|
||||||
|
|
||||||
// GetUser mocks base method.
|
// GetUser mocks base method.
|
||||||
func (m *MockStore) GetUser(ctx context.Context, username string) (db.GetUserRow, error) {
|
func (m *MockStore) GetUser(ctx context.Context, username string) (db.GetUserRow, error) {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
@ -443,6 +488,20 @@ func (mr *MockStoreMockRecorder) GetUserStats(ctx, user_id any) *gomock.Call {
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserStats", reflect.TypeOf((*MockStore)(nil).GetUserStats), ctx, user_id)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserStats", reflect.TypeOf((*MockStore)(nil).GetUserStats), ctx, user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecordLocationVisit mocks base method.
|
||||||
|
func (m *MockStore) RecordLocationVisit(ctx context.Context, locationID int32) error {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "RecordLocationVisit", ctx, locationID)
|
||||||
|
ret0, _ := ret[0].(error)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordLocationVisit indicates an expected call of RecordLocationVisit.
|
||||||
|
func (mr *MockStoreMockRecorder) RecordLocationVisit(ctx, locationID any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecordLocationVisit", reflect.TypeOf((*MockStore)(nil).RecordLocationVisit), ctx, locationID)
|
||||||
|
}
|
||||||
|
|
||||||
// RemoveFollowUser mocks base method.
|
// RemoveFollowUser mocks base method.
|
||||||
func (m *MockStore) RemoveFollowUser(ctx context.Context, arg db.RemoveFollowUserParams) error {
|
func (m *MockStore) RemoveFollowUser(ctx context.Context, arg db.RemoveFollowUserParams) error {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
@ -472,6 +531,20 @@ func (mr *MockStoreMockRecorder) UpdateAvatar(ctx, arg any) *gomock.Call {
|
|||||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAvatar", reflect.TypeOf((*MockStore)(nil).UpdateAvatar), ctx, arg)
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAvatar", reflect.TypeOf((*MockStore)(nil).UpdateAvatar), ctx, arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateLocationBusinessHours mocks base method.
|
||||||
|
func (m *MockStore) UpdateLocationBusinessHours(ctx context.Context, arg db.UpdateLocationBusinessHoursParams) error {
|
||||||
|
m.ctrl.T.Helper()
|
||||||
|
ret := m.ctrl.Call(m, "UpdateLocationBusinessHours", ctx, arg)
|
||||||
|
ret0, _ := ret[0].(error)
|
||||||
|
return ret0
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLocationBusinessHours indicates an expected call of UpdateLocationBusinessHours.
|
||||||
|
func (mr *MockStoreMockRecorder) UpdateLocationBusinessHours(ctx, arg any) *gomock.Call {
|
||||||
|
mr.mock.ctrl.T.Helper()
|
||||||
|
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLocationBusinessHours", reflect.TypeOf((*MockStore)(nil).UpdateLocationBusinessHours), ctx, arg)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateLocationThumbnail mocks base method.
|
// UpdateLocationThumbnail mocks base method.
|
||||||
func (m *MockStore) UpdateLocationThumbnail(ctx context.Context, arg db.UpdateLocationThumbnailParams) error {
|
func (m *MockStore) UpdateLocationThumbnail(ctx context.Context, arg db.UpdateLocationThumbnailParams) error {
|
||||||
m.ctrl.T.Helper()
|
m.ctrl.T.Helper()
|
||||||
|
|||||||
@ -15,4 +15,10 @@ approved_by IS NOT NULL;
|
|||||||
-- name: UpdateLocationThumbnail :exec
|
-- name: UpdateLocationThumbnail :exec
|
||||||
UPDATE locations
|
UPDATE locations
|
||||||
SET thumbnail = $1
|
SET thumbnail = $1
|
||||||
WHERE id = $2;
|
WHERE id = $2;
|
||||||
|
|
||||||
|
-- name: UpdateLocationBusinessHours :exec
|
||||||
|
UPDATE locations
|
||||||
|
SET business_hours = $1,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $2;
|
||||||
|
|||||||
178
db/repository/location_visits.go
Normal file
178
db/repository/location_visits.go
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// All queries in this file are hand-written and intentionally NOT defined in
|
||||||
|
// `db/queries/*.sql`, so they are not regenerated by sqlc.
|
||||||
|
|
||||||
|
// TrendingWindow is the trailing time-window over which visits are summed
|
||||||
|
// to compute "trending". Values are expressed in days so they map to exact
|
||||||
|
// row ranges in the daily-bucket location_page_visits table.
|
||||||
|
type TrendingWindow string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TrendingWindowWeek TrendingWindow = "week" // 7 days
|
||||||
|
TrendingWindowMonth TrendingWindow = "month" // 30 days
|
||||||
|
TrendingWindow3Month TrendingWindow = "3month" // 90 days
|
||||||
|
TrendingWindowSemester TrendingWindow = "semester" // 180 days
|
||||||
|
TrendingWindowYear TrendingWindow = "year" // 365 days
|
||||||
|
)
|
||||||
|
|
||||||
|
// Days returns the trailing window length in days. Returns (0, false) for
|
||||||
|
// unknown values.
|
||||||
|
func (w TrendingWindow) Days() (int, bool) {
|
||||||
|
switch w {
|
||||||
|
case TrendingWindowWeek:
|
||||||
|
return 7, true
|
||||||
|
case TrendingWindowMonth:
|
||||||
|
return 30, true
|
||||||
|
case TrendingWindow3Month:
|
||||||
|
return 90, true
|
||||||
|
case TrendingWindowSemester:
|
||||||
|
return 180, true
|
||||||
|
case TrendingWindowYear:
|
||||||
|
return 365, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTrendingWindow returns the typed window for a string, or false if
|
||||||
|
// the string isn't recognised.
|
||||||
|
func ParseTrendingWindow(s string) (TrendingWindow, bool) {
|
||||||
|
w := TrendingWindow(s)
|
||||||
|
if _, ok := w.Days(); !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return w, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordLocationVisit increments today's visit bucket for the given location,
|
||||||
|
// creating the row if it does not yet exist. Idempotent within Postgres
|
||||||
|
// transactions; the (location_id, day) UNIQUE constraint guarantees a single
|
||||||
|
// row per location per day.
|
||||||
|
func (q *Queries) RecordLocationVisit(ctx context.Context, locationID int32) error {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO location_page_visits (location_id, day, visit_count, updated_at)
|
||||||
|
VALUES ($1, CURRENT_DATE, 1, now())
|
||||||
|
ON CONFLICT (location_id, day)
|
||||||
|
DO UPDATE SET
|
||||||
|
visit_count = location_page_visits.visit_count + 1,
|
||||||
|
updated_at = now()
|
||||||
|
`
|
||||||
|
_, err := q.db.Exec(ctx, query, locationID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLocationLifetimeVisits returns the all-time sum of visit_count for a
|
||||||
|
// single location. Cheap because the (location_id, day) index covers it.
|
||||||
|
func (q *Queries) GetLocationLifetimeVisits(ctx context.Context, locationID int32) (int64, error) {
|
||||||
|
const query = `
|
||||||
|
SELECT COALESCE(SUM(visit_count), 0)::bigint AS total
|
||||||
|
FROM location_page_visits
|
||||||
|
WHERE location_id = $1
|
||||||
|
`
|
||||||
|
row := q.db.QueryRow(ctx, query, locationID)
|
||||||
|
var total int64
|
||||||
|
err := row.Scan(&total)
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetTrendingLocationsParams struct {
|
||||||
|
Window TrendingWindow `json:"window"`
|
||||||
|
Limit int32 `json:"limit"`
|
||||||
|
Offset int32 `json:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetTrendingLocationsRow struct {
|
||||||
|
RowNumber int32 `json:"row_number"`
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
LocationType string `json:"location_type"`
|
||||||
|
GoogleMapsLink pgtype.Text `json:"google_maps_link"`
|
||||||
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
|
RegencyName string `json:"regency_name"`
|
||||||
|
ProvinceName string `json:"province_name"`
|
||||||
|
RegionName string `json:"region_name"`
|
||||||
|
WindowVisits int64 `json:"window_visits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrendingLocations ranks approved, non-deleted locations by total page
|
||||||
|
// visits over the trailing window. The query is keyed off the
|
||||||
|
// idx_location_page_visits_day_count index (day, visit_count DESC).
|
||||||
|
//
|
||||||
|
// The bound day count is bound as a parameter; the planner still treats it as
|
||||||
|
// a constant for the row (current_date - $1::int), so the index range scan is
|
||||||
|
// effective.
|
||||||
|
func (q *Queries) GetTrendingLocations(ctx context.Context, arg GetTrendingLocationsParams) ([]GetTrendingLocationsRow, error) {
|
||||||
|
days, ok := arg.Window.Days()
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid trending window %q", arg.Window)
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT
|
||||||
|
row_number() OVER (ORDER BY window_visits DESC, l.id ASC) AS row_number,
|
||||||
|
l.id,
|
||||||
|
l.name,
|
||||||
|
l.address,
|
||||||
|
l.location_type::text AS location_type,
|
||||||
|
l.google_maps_link,
|
||||||
|
l.thumbnail,
|
||||||
|
COALESCE(re.regency_name, '') AS regency_name,
|
||||||
|
COALESCE(prov.province_name, '') AS province_name,
|
||||||
|
COALESCE(reg.region_name, '') AS region_name,
|
||||||
|
window_visits
|
||||||
|
FROM (
|
||||||
|
SELECT v.location_id, SUM(v.visit_count)::bigint AS window_visits
|
||||||
|
FROM location_page_visits v
|
||||||
|
WHERE v.day >= (CURRENT_DATE - $1::int)
|
||||||
|
GROUP BY v.location_id
|
||||||
|
) agg
|
||||||
|
JOIN locations l ON l.id = agg.location_id
|
||||||
|
JOIN regencies re ON re.id = l.regency_id
|
||||||
|
JOIN provinces prov ON prov.id = re.province_id
|
||||||
|
JOIN regions reg ON reg.id = prov.region_id
|
||||||
|
WHERE l.is_deleted = false
|
||||||
|
AND l.approved_by IS NOT NULL
|
||||||
|
ORDER BY window_visits DESC, l.id ASC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := q.db.Query(ctx, query, days, arg.Limit, arg.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := []GetTrendingLocationsRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetTrendingLocationsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.RowNumber,
|
||||||
|
&i.ID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Address,
|
||||||
|
&i.LocationType,
|
||||||
|
&i.GoogleMapsLink,
|
||||||
|
&i.Thumbnail,
|
||||||
|
&i.RegencyName,
|
||||||
|
&i.ProvinceName,
|
||||||
|
&i.RegionName,
|
||||||
|
&i.WindowVisits,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@ -204,6 +204,7 @@ type GetLocationRow struct {
|
|||||||
RegencyName string `json:"regency_name"`
|
RegencyName string `json:"regency_name"`
|
||||||
ProvinceName string `json:"province_name"`
|
ProvinceName string `json:"province_name"`
|
||||||
RegionName string `json:"region_name"`
|
RegionName string `json:"region_name"`
|
||||||
|
LocationType string `json:"location_type"`
|
||||||
GoogleMapsLink string `json:"google_maps_link"`
|
GoogleMapsLink string `json:"google_maps_link"`
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
SubmittedBy string `json:"submitted_by"`
|
SubmittedBy string `json:"submitted_by"`
|
||||||
@ -211,7 +212,8 @@ type GetLocationRow struct {
|
|||||||
CriticCount int32 `json:"critic_count"`
|
CriticCount int32 `json:"critic_count"`
|
||||||
UserScore int32 `json:"user_score"`
|
UserScore int32 `json:"user_score"`
|
||||||
UserCount int32 `json:"user_count"`
|
UserCount int32 `json:"user_count"`
|
||||||
Amenities string `json:"amenities"`
|
Amenities []byte `json:"amenities"`
|
||||||
|
BusinessHours []byte `json:"business_hours"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var getLocationQ = `
|
var getLocationQ = `
|
||||||
@ -225,10 +227,13 @@ var getLocationQ = `
|
|||||||
COALESCE(l.google_maps_link, '') as google_maps_link,
|
COALESCE(l.google_maps_link, '') as google_maps_link,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
u.username as submitted_by,
|
u.username as submitted_by,
|
||||||
|
l.location_type,
|
||||||
(SELECT COALESCE(SUM(score), 0) from reviews re where re.is_from_critic = true and re.location_id = l.id) as critic_score,
|
(SELECT COALESCE(SUM(score), 0) from reviews re where re.is_from_critic = true and re.location_id = l.id) as critic_score,
|
||||||
(SELECT COUNT(id) from reviews re where re.is_from_critic = true and re.location_id = l.id) as critic_count,
|
(SELECT COUNT(id) from reviews re where re.is_from_critic = true and re.location_id = l.id) as critic_count,
|
||||||
(SELECT COALESCE(SUM(score), 0) from reviews re where re.is_from_critic = false and re.location_id = l.id) as user_score,
|
(SELECT COALESCE(SUM(score), 0) from reviews re where re.is_from_critic = false and re.location_id = l.id) as user_score,
|
||||||
(SELECT COUNT(id) from reviews re where re.is_from_critic = false and re.location_id = l.id) as user_count
|
(SELECT COUNT(id) from reviews re where re.is_from_critic = false and re.location_id = l.id) as user_count,
|
||||||
|
l.amenities,
|
||||||
|
l.business_hours
|
||||||
FROM locations l
|
FROM locations l
|
||||||
JOIN regencies re on re.id = l.regency_id
|
JOIN regencies re on re.id = l.regency_id
|
||||||
JOIN provinces prov on prov.id = re.province_id
|
JOIN provinces prov on prov.id = re.province_id
|
||||||
@ -251,10 +256,13 @@ func (q *Queries) GetLocation(ctx context.Context, location_id int32) (GetLocati
|
|||||||
&i.GoogleMapsLink,
|
&i.GoogleMapsLink,
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
&i.SubmittedBy,
|
&i.SubmittedBy,
|
||||||
|
&i.LocationType,
|
||||||
&i.CriticScore,
|
&i.CriticScore,
|
||||||
&i.CriticCount,
|
&i.CriticCount,
|
||||||
&i.UserScore,
|
&i.UserScore,
|
||||||
&i.UserCount,
|
&i.UserCount,
|
||||||
|
&i.Amenities,
|
||||||
|
&i.BusinessHours,
|
||||||
)
|
)
|
||||||
|
|
||||||
return i, err
|
return i, err
|
||||||
@ -270,9 +278,10 @@ INSERT INTO locations(
|
|||||||
google_maps_link,
|
google_maps_link,
|
||||||
approved_by,
|
approved_by,
|
||||||
amenities,
|
amenities,
|
||||||
|
business_hours,
|
||||||
is_deleted
|
is_deleted
|
||||||
) values (
|
) values (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
||||||
)
|
)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
@ -287,6 +296,7 @@ type CreateLocationParams struct {
|
|||||||
IsDeleted bool `json:"is_deleted"`
|
IsDeleted bool `json:"is_deleted"`
|
||||||
ApprovedBy pgtype.Int4 `json:"approved_by"`
|
ApprovedBy pgtype.Int4 `json:"approved_by"`
|
||||||
Amenities pgtype.Text `json:"amenities"`
|
Amenities pgtype.Text `json:"amenities"`
|
||||||
|
BusinessHours pgtype.Text `json:"business_hours"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateLocation(ctx context.Context, arg CreateLocationParams) (int32, error) {
|
func (q *Queries) CreateLocation(ctx context.Context, arg CreateLocationParams) (int32, error) {
|
||||||
@ -299,6 +309,7 @@ func (q *Queries) CreateLocation(ctx context.Context, arg CreateLocationParams)
|
|||||||
arg.GoogleMapsLink,
|
arg.GoogleMapsLink,
|
||||||
arg.ApprovedBy,
|
arg.ApprovedBy,
|
||||||
arg.Amenities,
|
arg.Amenities,
|
||||||
|
arg.BusinessHours,
|
||||||
arg.IsDeleted,
|
arg.IsDeleted,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const getListLocations = `-- name: GetListLocations :many
|
const getListLocations = `-- name: GetListLocations :many
|
||||||
SELECT id, address, name, google_maps_link, location_type, submitted_by, total_visited, thumbnail, regency_id, is_deleted, created_at, updated_at, approved_by, approved_at FROM locations
|
SELECT id, address, name, google_maps_link, location_type, submitted_by, thumbnail, regency_id, is_deleted, created_at, updated_at, approved_by, approved_at, amenities, business_hours FROM locations
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetListLocations(ctx context.Context) ([]Location, error) {
|
func (q *Queries) GetListLocations(ctx context.Context) ([]Location, error) {
|
||||||
@ -31,7 +31,6 @@ func (q *Queries) GetListLocations(ctx context.Context) ([]Location, error) {
|
|||||||
&i.GoogleMapsLink,
|
&i.GoogleMapsLink,
|
||||||
&i.LocationType,
|
&i.LocationType,
|
||||||
&i.SubmittedBy,
|
&i.SubmittedBy,
|
||||||
&i.TotalVisited,
|
|
||||||
&i.Thumbnail,
|
&i.Thumbnail,
|
||||||
&i.RegencyID,
|
&i.RegencyID,
|
||||||
&i.IsDeleted,
|
&i.IsDeleted,
|
||||||
@ -39,6 +38,8 @@ func (q *Queries) GetListLocations(ctx context.Context) ([]Location, error) {
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.ApprovedBy,
|
&i.ApprovedBy,
|
||||||
&i.ApprovedAt,
|
&i.ApprovedAt,
|
||||||
|
&i.Amenities,
|
||||||
|
&i.BusinessHours,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -81,6 +82,23 @@ func (q *Queries) GetLocationTag(ctx context.Context, targetID int32) ([]string,
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateLocationBusinessHours = `-- name: UpdateLocationBusinessHours :exec
|
||||||
|
UPDATE locations
|
||||||
|
SET business_hours = $1,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateLocationBusinessHoursParams struct {
|
||||||
|
BusinessHours []byte `json:"business_hours"`
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateLocationBusinessHours(ctx context.Context, arg UpdateLocationBusinessHoursParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, updateLocationBusinessHours, arg.BusinessHours, arg.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const updateLocationThumbnail = `-- name: UpdateLocationThumbnail :exec
|
const updateLocationThumbnail = `-- name: UpdateLocationThumbnail :exec
|
||||||
UPDATE locations
|
UPDATE locations
|
||||||
SET thumbnail = $1
|
SET thumbnail = $1
|
||||||
|
|||||||
@ -2,6 +2,8 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
const createMenuItems = `-- name: CreateMenuItems :exec
|
const createMenuItems = `-- name: CreateMenuItems :exec
|
||||||
@ -29,6 +31,64 @@ type CreateMenuItemsParams struct {
|
|||||||
SubmittedBy int32 `json:"submitted_by"`
|
SubmittedBy int32 `json:"submitted_by"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getMenuItems = `-- name: GetMenuItems :many
|
||||||
|
SELECT
|
||||||
|
m.id,
|
||||||
|
m.location_id,
|
||||||
|
m.name,
|
||||||
|
m.price,
|
||||||
|
m.category,
|
||||||
|
m.description,
|
||||||
|
m.is_available,
|
||||||
|
m.submitted_by,
|
||||||
|
ROUND(AVG(r.score::float8))::float8 AS avg_score
|
||||||
|
FROM menu_items m
|
||||||
|
LEFT JOIN menu_item_reviews r ON r.menu_item_id = m.id AND r.is_hidden = false
|
||||||
|
WHERE ($1::int4 IS NULL OR m.location_id = $1::int4)
|
||||||
|
GROUP BY m.id
|
||||||
|
ORDER BY m.id ASC
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetMenuItemsRow struct {
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
LocationID int32 `json:"location_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Price int32 `json:"price"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsAvailable bool `json:"is_available"`
|
||||||
|
SubmittedBy int32 `json:"submitted_by"`
|
||||||
|
AvgScore *float64 `json:"avg_score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetMenuItems(ctx context.Context, locationID pgtype.Int4) ([]GetMenuItemsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, getMenuItems, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []GetMenuItemsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i GetMenuItemsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.LocationID,
|
||||||
|
&i.Name,
|
||||||
|
&i.Price,
|
||||||
|
&i.Category,
|
||||||
|
&i.Description,
|
||||||
|
&i.IsAvailable,
|
||||||
|
&i.SubmittedBy,
|
||||||
|
&i.AvgScore,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateMenuItems(ctx context.Context, arg CreateMenuItemsParams) (int32, error) {
|
func (q *Queries) CreateMenuItems(ctx context.Context, arg CreateMenuItemsParams) (int32, error) {
|
||||||
row := q.db.QueryRow(ctx, createMenuItems,
|
row := q.db.QueryRow(ctx, createMenuItems,
|
||||||
arg.LocationID,
|
arg.LocationID,
|
||||||
|
|||||||
@ -101,6 +101,51 @@ func (ns NullLocationType) Value() (driver.Value, error) {
|
|||||||
return string(ns.LocationType), nil
|
return string(ns.LocationType), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MenuItemCategory string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MenuItemCategoryDessert MenuItemCategory = "dessert"
|
||||||
|
MenuItemCategoryMainCourse MenuItemCategory = "main_course"
|
||||||
|
MenuItemCategoryBeverages MenuItemCategory = "beverages"
|
||||||
|
MenuItemCategoryAppetizer MenuItemCategory = "appetizer"
|
||||||
|
MenuItemCategorySnack MenuItemCategory = "snack"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *MenuItemCategory) Scan(src interface{}) error {
|
||||||
|
switch s := src.(type) {
|
||||||
|
case []byte:
|
||||||
|
*e = MenuItemCategory(s)
|
||||||
|
case string:
|
||||||
|
*e = MenuItemCategory(s)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported scan type for MenuItemCategory: %T", src)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type NullMenuItemCategory struct {
|
||||||
|
MenuItemCategory MenuItemCategory `json:"menu_item_category"`
|
||||||
|
Valid bool `json:"valid"` // Valid is true if MenuItemCategory is not NULL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan implements the Scanner interface.
|
||||||
|
func (ns *NullMenuItemCategory) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
ns.MenuItemCategory, ns.Valid = "", false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ns.Valid = true
|
||||||
|
return ns.MenuItemCategory.Scan(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements the driver Valuer interface.
|
||||||
|
func (ns NullMenuItemCategory) Value() (driver.Value, error) {
|
||||||
|
if !ns.Valid {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return string(ns.MenuItemCategory), nil
|
||||||
|
}
|
||||||
|
|
||||||
type UserReportsType string
|
type UserReportsType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -192,7 +237,6 @@ type Location struct {
|
|||||||
GoogleMapsLink pgtype.Text `json:"google_maps_link"`
|
GoogleMapsLink pgtype.Text `json:"google_maps_link"`
|
||||||
LocationType LocationType `json:"location_type"`
|
LocationType LocationType `json:"location_type"`
|
||||||
SubmittedBy int32 `json:"submitted_by"`
|
SubmittedBy int32 `json:"submitted_by"`
|
||||||
TotalVisited pgtype.Int4 `json:"total_visited"`
|
|
||||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||||
RegencyID int16 `json:"regency_id"`
|
RegencyID int16 `json:"regency_id"`
|
||||||
IsDeleted bool `json:"is_deleted"`
|
IsDeleted bool `json:"is_deleted"`
|
||||||
@ -200,6 +244,8 @@ type Location struct {
|
|||||||
UpdatedAt pgtype.Timestamp `json:"updated_at"`
|
UpdatedAt pgtype.Timestamp `json:"updated_at"`
|
||||||
ApprovedBy pgtype.Int4 `json:"approved_by"`
|
ApprovedBy pgtype.Int4 `json:"approved_by"`
|
||||||
ApprovedAt pgtype.Timestamp `json:"approved_at"`
|
ApprovedAt pgtype.Timestamp `json:"approved_at"`
|
||||||
|
Amenities []byte `json:"amenities"`
|
||||||
|
BusinessHours []byte `json:"business_hours"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocationImage struct {
|
type LocationImage struct {
|
||||||
@ -214,12 +260,43 @@ type LocationImage struct {
|
|||||||
type LocationPageVisit struct {
|
type LocationPageVisit struct {
|
||||||
ID int32 `json:"id"`
|
ID int32 `json:"id"`
|
||||||
LocationID int32 `json:"location_id"`
|
LocationID int32 `json:"location_id"`
|
||||||
WeekKey string `json:"week_key"`
|
Day pgtype.Date `json:"day"`
|
||||||
VisitCount int64 `json:"visit_count"`
|
VisitCount int64 `json:"visit_count"`
|
||||||
IsDeleted pgtype.Bool `json:"is_deleted"`
|
|
||||||
UpdatedAt pgtype.Timestamp `json:"updated_at"`
|
UpdatedAt pgtype.Timestamp `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MenuItem struct {
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
LocationID int32 `json:"location_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Price pgtype.Int4 `json:"price"`
|
||||||
|
Category NullMenuItemCategory `json:"category"`
|
||||||
|
Description pgtype.Text `json:"description"`
|
||||||
|
IsAvailable pgtype.Bool `json:"is_available"`
|
||||||
|
IsDeleted pgtype.Bool `json:"is_deleted"`
|
||||||
|
SubmittedBy int32 `json:"submitted_by"`
|
||||||
|
CreatedAt pgtype.Timestamp `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamp `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MenuItemPriceHistory struct {
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
MenuItemID int32 `json:"menu_item_id"`
|
||||||
|
Price int32 `json:"price"`
|
||||||
|
RecordedAt pgtype.Timestamp `json:"recorded_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MenuItemReview struct {
|
||||||
|
ID int32 `json:"id"`
|
||||||
|
MenuItemID int32 `json:"menu_item_id"`
|
||||||
|
SubmittedBy int32 `json:"submitted_by"`
|
||||||
|
Score int16 `json:"score"`
|
||||||
|
Comments pgtype.Text `json:"comments"`
|
||||||
|
IsHidden pgtype.Bool `json:"is_hidden"`
|
||||||
|
CreatedAt pgtype.Timestamp `json:"created_at"`
|
||||||
|
UpdatedAt pgtype.Timestamp `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type NewsEvent struct {
|
type NewsEvent struct {
|
||||||
ID int32 `json:"id"`
|
ID int32 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@ -258,17 +335,22 @@ type Region struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Review struct {
|
type Review struct {
|
||||||
ID int32 `json:"id"`
|
ID int32 `json:"id"`
|
||||||
Title pgtype.Text `json:"title"`
|
Title pgtype.Text `json:"title"`
|
||||||
SubmittedBy int32 `json:"submitted_by"`
|
SubmittedBy int32 `json:"submitted_by"`
|
||||||
Comments string `json:"comments"`
|
Comments string `json:"comments"`
|
||||||
Score int16 `json:"score"`
|
Score int16 `json:"score"`
|
||||||
IsFromCritic bool `json:"is_from_critic"`
|
IsFromCritic bool `json:"is_from_critic"`
|
||||||
CostApprox pgtype.Int4 `json:"cost_approx"`
|
CostApprox pgtype.Int4 `json:"cost_approx"`
|
||||||
IsHided bool `json:"is_hided"`
|
IsHided bool `json:"is_hided"`
|
||||||
LocationID int32 `json:"location_id"`
|
LocationID int32 `json:"location_id"`
|
||||||
CreatedAt pgtype.Timestamp `json:"created_at"`
|
CreatedAt pgtype.Timestamp `json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamp `json:"updated_at"`
|
UpdatedAt pgtype.Timestamp `json:"updated_at"`
|
||||||
|
TasteGrade pgtype.Float8 `json:"taste_grade"`
|
||||||
|
ComfortGrade pgtype.Float8 `json:"comfort_grade"`
|
||||||
|
CleanlinessGrade pgtype.Float8 `json:"cleanliness_grade"`
|
||||||
|
ServiceGrade pgtype.Float8 `json:"service_grade"`
|
||||||
|
FacilitiesGrade pgtype.Float8 `json:"facilities_grade"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tag struct {
|
type Tag struct {
|
||||||
|
|||||||
@ -26,6 +26,7 @@ type Querier interface {
|
|||||||
GetUserReviewByLocation(ctx context.Context, arg GetUserReviewByLocationParams) (GetUserReviewByLocationRow, error)
|
GetUserReviewByLocation(ctx context.Context, arg GetUserReviewByLocationParams) (GetUserReviewByLocationRow, error)
|
||||||
RemoveFollowUser(ctx context.Context, arg RemoveFollowUserParams) error
|
RemoveFollowUser(ctx context.Context, arg RemoveFollowUserParams) error
|
||||||
UpdateAvatar(ctx context.Context, arg UpdateAvatarParams) (pgtype.Text, error)
|
UpdateAvatar(ctx context.Context, arg UpdateAvatarParams) (pgtype.Text, error)
|
||||||
|
UpdateLocationBusinessHours(ctx context.Context, arg UpdateLocationBusinessHoursParams) error
|
||||||
UpdateLocationThumbnail(ctx context.Context, arg UpdateLocationThumbnailParams) error
|
UpdateLocationThumbnail(ctx context.Context, arg UpdateLocationThumbnailParams) error
|
||||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -24,6 +25,10 @@ type Store interface {
|
|||||||
GetNewsEventsList(ctx context.Context, arg GetNewsEventsListParams) ([]NewsEventRow, error)
|
GetNewsEventsList(ctx context.Context, arg GetNewsEventsListParams) ([]NewsEventRow, error)
|
||||||
GetListRecentLocationsWithRatings(ctx context.Context, arg GetListRecentLocationsParams) ([]GetListRecentLocationsWithRatingsRow, error)
|
GetListRecentLocationsWithRatings(ctx context.Context, arg GetListRecentLocationsParams) ([]GetListRecentLocationsWithRatingsRow, error)
|
||||||
GetReviewByLocationAndId(ctx context.Context, arg GetReviewByLocationAndIdParams) (GetReviewByLocationAndId, error)
|
GetReviewByLocationAndId(ctx context.Context, arg GetReviewByLocationAndIdParams) (GetReviewByLocationAndId, error)
|
||||||
|
GetMenuItems(ctx context.Context, locationID pgtype.Int4) ([]GetMenuItemsRow, error)
|
||||||
|
RecordLocationVisit(ctx context.Context, locationID int32) error
|
||||||
|
GetLocationLifetimeVisits(ctx context.Context, locationID int32) (int64, error)
|
||||||
|
GetTrendingLocations(ctx context.Context, arg GetTrendingLocationsParams) ([]GetTrendingLocationsRow, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SQLStore struct {
|
type SQLStore struct {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ type CreateLocationTxParams struct {
|
|||||||
RegencyID int16 `json:"regency_id"`
|
RegencyID int16 `json:"regency_id"`
|
||||||
Amenities pgtype.Text `json:"amenities"`
|
Amenities pgtype.Text `json:"amenities"`
|
||||||
RestaurantMenu pgtype.Text `json:"restaurant_menu"`
|
RestaurantMenu pgtype.Text `json:"restaurant_menu"`
|
||||||
|
BusinessHours pgtype.Text `json:"business_hours"`
|
||||||
GoogleMapsLink pgtype.Text `json:"google_maps_link"`
|
GoogleMapsLink pgtype.Text `json:"google_maps_link"`
|
||||||
IsDeleted bool `json:"is_deleted"`
|
IsDeleted bool `json:"is_deleted"`
|
||||||
ApprovedBy pgtype.Int4 `json:"approved_by"`
|
ApprovedBy pgtype.Int4 `json:"approved_by"`
|
||||||
@ -35,6 +36,7 @@ func (store *SQLStore) CreateLocationTx(ctx context.Context, arg CreateLocationT
|
|||||||
IsDeleted: arg.IsDeleted,
|
IsDeleted: arg.IsDeleted,
|
||||||
ApprovedBy: arg.ApprovedBy,
|
ApprovedBy: arg.ApprovedBy,
|
||||||
Amenities: arg.Amenities,
|
Amenities: arg.Amenities,
|
||||||
|
BusinessHours: arg.BusinessHours,
|
||||||
})
|
})
|
||||||
|
|
||||||
if arg.LocationType == LocationTypeCulinary && arg.RestaurantMenu.Valid {
|
if arg.LocationType == LocationTypeCulinary && arg.RestaurantMenu.Valid {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user