update
This commit is contained in:
parent
67eb0a5a66
commit
305beabacb
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,4 +1,6 @@
|
||||
*.env
|
||||
data.ms
|
||||
tmp
|
||||
public
|
||||
public
|
||||
**/.DS_Store
|
||||
.idea
|
||||
161
api/location.go
161
api/location.go
@ -6,6 +6,8 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
db "git.nochill.in/nochill/hiling_go/db/repository"
|
||||
@ -17,6 +19,63 @@ import (
|
||||
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"`
|
||||
@ -25,6 +84,7 @@ type createLocationReq struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@ -38,6 +98,7 @@ type createLocationReq struct {
|
||||
// @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
|
||||
@ -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{
|
||||
Address: req.Address,
|
||||
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},
|
||||
Amenities: amenities,
|
||||
RestaurantMenu: restaurantMenu,
|
||||
BusinessHours: businessHours,
|
||||
Thumbnail: tempImg,
|
||||
}
|
||||
|
||||
err := server.Store.CreateLocationTx(ctx, arg)
|
||||
err = server.Store.CreateLocationTx(ctx, arg)
|
||||
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
router.GET("/locations/recent", server.getListRecentLocationsWithRatings)
|
||||
router.GET("/locations/top-ratings", server.getTopListLocations)
|
||||
router.GET("/locations/trending", server.getTrendingLocations)
|
||||
router.GET("/locations", server.getListLocations)
|
||||
router.GET("/location/:location_id", server.getLocation)
|
||||
router.GET("/location/tags/:location_id", server.getTagsByLocation)
|
||||
router.GET("/location/reviews", server.getListLocationReviews)
|
||||
router.GET("/location/:location_id/review/:review_id", server.getReview)
|
||||
router.GET("/locations/search", server.searchLocations)
|
||||
router.POST("/location/:location_id/visit", server.recordLocationVisit)
|
||||
|
||||
//IMAGES
|
||||
router.GET("/images/location", server.getAllImagesByLocation)
|
||||
|
||||
// MENU ITEMS
|
||||
router.GET("/menu-items", server.getMenuItems)
|
||||
|
||||
// NEWS / EVENTS
|
||||
router.GET("/news-events", server.GetNewsEventsList)
|
||||
|
||||
|
||||
@ -154,7 +154,6 @@ func randomLocation() db.Location {
|
||||
Name: util.RandomString(10),
|
||||
GoogleMapsLink: pgtype.Text{Valid: true, String: util.RandomString(20)},
|
||||
SubmittedBy: 1,
|
||||
TotalVisited: pgtype.Int4{Valid: true, Int32: int32(util.RandomInt(0, 32))},
|
||||
Thumbnail: pgtype.Text{Valid: false, String: ""},
|
||||
RegencyID: 1305,
|
||||
IsDeleted: false,
|
||||
|
||||
@ -56,4 +56,4 @@ CREATE TABLE menu_item_reviews (
|
||||
"updated_at" timestamp default(now()),
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (m *MockStore) GetLocationTag(ctx context.Context, targetID int32) ([]string, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (m *MockStore) GetNewsEventsList(ctx context.Context, arg db.GetNewsEventsListParams) ([]db.NewsEventRow, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (m *MockStore) GetUser(ctx context.Context, username string) (db.GetUserRow, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (m *MockStore) RemoveFollowUser(ctx context.Context, arg db.RemoveFollowUserParams) error {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (m *MockStore) UpdateLocationThumbnail(ctx context.Context, arg db.UpdateLocationThumbnailParams) error {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@ -15,4 +15,10 @@ approved_by IS NOT NULL;
|
||||
-- name: UpdateLocationThumbnail :exec
|
||||
UPDATE locations
|
||||
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"`
|
||||
ProvinceName string `json:"province_name"`
|
||||
RegionName string `json:"region_name"`
|
||||
LocationType string `json:"location_type"`
|
||||
GoogleMapsLink string `json:"google_maps_link"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
SubmittedBy string `json:"submitted_by"`
|
||||
@ -211,7 +212,8 @@ type GetLocationRow struct {
|
||||
CriticCount int32 `json:"critic_count"`
|
||||
UserScore int32 `json:"user_score"`
|
||||
UserCount int32 `json:"user_count"`
|
||||
Amenities string `json:"amenities"`
|
||||
Amenities []byte `json:"amenities"`
|
||||
BusinessHours []byte `json:"business_hours"`
|
||||
}
|
||||
|
||||
var getLocationQ = `
|
||||
@ -225,10 +227,13 @@ var getLocationQ = `
|
||||
COALESCE(l.google_maps_link, '') as google_maps_link,
|
||||
thumbnail,
|
||||
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 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 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
|
||||
JOIN regencies re on re.id = l.regency_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.Thumbnail,
|
||||
&i.SubmittedBy,
|
||||
&i.LocationType,
|
||||
&i.CriticScore,
|
||||
&i.CriticCount,
|
||||
&i.UserScore,
|
||||
&i.UserCount,
|
||||
&i.Amenities,
|
||||
&i.BusinessHours,
|
||||
)
|
||||
|
||||
return i, err
|
||||
@ -270,9 +278,10 @@ INSERT INTO locations(
|
||||
google_maps_link,
|
||||
approved_by,
|
||||
amenities,
|
||||
business_hours,
|
||||
is_deleted
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
|
||||
)
|
||||
RETURNING id
|
||||
`
|
||||
@ -287,6 +296,7 @@ type CreateLocationParams struct {
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
ApprovedBy pgtype.Int4 `json:"approved_by"`
|
||||
Amenities pgtype.Text `json:"amenities"`
|
||||
BusinessHours pgtype.Text `json:"business_hours"`
|
||||
}
|
||||
|
||||
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.ApprovedBy,
|
||||
arg.Amenities,
|
||||
arg.BusinessHours,
|
||||
arg.IsDeleted,
|
||||
)
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
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) {
|
||||
@ -31,7 +31,6 @@ func (q *Queries) GetListLocations(ctx context.Context) ([]Location, error) {
|
||||
&i.GoogleMapsLink,
|
||||
&i.LocationType,
|
||||
&i.SubmittedBy,
|
||||
&i.TotalVisited,
|
||||
&i.Thumbnail,
|
||||
&i.RegencyID,
|
||||
&i.IsDeleted,
|
||||
@ -39,6 +38,8 @@ func (q *Queries) GetListLocations(ctx context.Context) ([]Location, error) {
|
||||
&i.UpdatedAt,
|
||||
&i.ApprovedBy,
|
||||
&i.ApprovedAt,
|
||||
&i.Amenities,
|
||||
&i.BusinessHours,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -81,6 +82,23 @@ func (q *Queries) GetLocationTag(ctx context.Context, targetID int32) ([]string,
|
||||
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
|
||||
UPDATE locations
|
||||
SET thumbnail = $1
|
||||
|
||||
@ -2,6 +2,8 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createMenuItems = `-- name: CreateMenuItems :exec
|
||||
@ -29,6 +31,64 @@ type CreateMenuItemsParams struct {
|
||||
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) {
|
||||
row := q.db.QueryRow(ctx, createMenuItems,
|
||||
arg.LocationID,
|
||||
|
||||
@ -101,6 +101,51 @@ func (ns NullLocationType) Value() (driver.Value, error) {
|
||||
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
|
||||
|
||||
const (
|
||||
@ -192,7 +237,6 @@ type Location struct {
|
||||
GoogleMapsLink pgtype.Text `json:"google_maps_link"`
|
||||
LocationType LocationType `json:"location_type"`
|
||||
SubmittedBy int32 `json:"submitted_by"`
|
||||
TotalVisited pgtype.Int4 `json:"total_visited"`
|
||||
Thumbnail pgtype.Text `json:"thumbnail"`
|
||||
RegencyID int16 `json:"regency_id"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
@ -200,6 +244,8 @@ type Location struct {
|
||||
UpdatedAt pgtype.Timestamp `json:"updated_at"`
|
||||
ApprovedBy pgtype.Int4 `json:"approved_by"`
|
||||
ApprovedAt pgtype.Timestamp `json:"approved_at"`
|
||||
Amenities []byte `json:"amenities"`
|
||||
BusinessHours []byte `json:"business_hours"`
|
||||
}
|
||||
|
||||
type LocationImage struct {
|
||||
@ -214,12 +260,43 @@ type LocationImage struct {
|
||||
type LocationPageVisit struct {
|
||||
ID int32 `json:"id"`
|
||||
LocationID int32 `json:"location_id"`
|
||||
WeekKey string `json:"week_key"`
|
||||
Day pgtype.Date `json:"day"`
|
||||
VisitCount int64 `json:"visit_count"`
|
||||
IsDeleted pgtype.Bool `json:"is_deleted"`
|
||||
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 {
|
||||
ID int32 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@ -258,17 +335,22 @@ type Region struct {
|
||||
}
|
||||
|
||||
type Review struct {
|
||||
ID int32 `json:"id"`
|
||||
Title pgtype.Text `json:"title"`
|
||||
SubmittedBy int32 `json:"submitted_by"`
|
||||
Comments string `json:"comments"`
|
||||
Score int16 `json:"score"`
|
||||
IsFromCritic bool `json:"is_from_critic"`
|
||||
CostApprox pgtype.Int4 `json:"cost_approx"`
|
||||
IsHided bool `json:"is_hided"`
|
||||
LocationID int32 `json:"location_id"`
|
||||
CreatedAt pgtype.Timestamp `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamp `json:"updated_at"`
|
||||
ID int32 `json:"id"`
|
||||
Title pgtype.Text `json:"title"`
|
||||
SubmittedBy int32 `json:"submitted_by"`
|
||||
Comments string `json:"comments"`
|
||||
Score int16 `json:"score"`
|
||||
IsFromCritic bool `json:"is_from_critic"`
|
||||
CostApprox pgtype.Int4 `json:"cost_approx"`
|
||||
IsHided bool `json:"is_hided"`
|
||||
LocationID int32 `json:"location_id"`
|
||||
CreatedAt pgtype.Timestamp `json:"created_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 {
|
||||
|
||||
@ -26,6 +26,7 @@ type Querier interface {
|
||||
GetUserReviewByLocation(ctx context.Context, arg GetUserReviewByLocationParams) (GetUserReviewByLocationRow, error)
|
||||
RemoveFollowUser(ctx context.Context, arg RemoveFollowUserParams) error
|
||||
UpdateAvatar(ctx context.Context, arg UpdateAvatarParams) (pgtype.Text, error)
|
||||
UpdateLocationBusinessHours(ctx context.Context, arg UpdateLocationBusinessHoursParams) error
|
||||
UpdateLocationThumbnail(ctx context.Context, arg UpdateLocationThumbnailParams) error
|
||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@ -24,6 +25,10 @@ type Store interface {
|
||||
GetNewsEventsList(ctx context.Context, arg GetNewsEventsListParams) ([]NewsEventRow, error)
|
||||
GetListRecentLocationsWithRatings(ctx context.Context, arg GetListRecentLocationsParams) ([]GetListRecentLocationsWithRatingsRow, 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 {
|
||||
|
||||
@ -15,6 +15,7 @@ type CreateLocationTxParams struct {
|
||||
RegencyID int16 `json:"regency_id"`
|
||||
Amenities pgtype.Text `json:"amenities"`
|
||||
RestaurantMenu pgtype.Text `json:"restaurant_menu"`
|
||||
BusinessHours pgtype.Text `json:"business_hours"`
|
||||
GoogleMapsLink pgtype.Text `json:"google_maps_link"`
|
||||
IsDeleted bool `json:"is_deleted"`
|
||||
ApprovedBy pgtype.Int4 `json:"approved_by"`
|
||||
@ -35,6 +36,7 @@ func (store *SQLStore) CreateLocationTx(ctx context.Context, arg CreateLocationT
|
||||
IsDeleted: arg.IsDeleted,
|
||||
ApprovedBy: arg.ApprovedBy,
|
||||
Amenities: arg.Amenities,
|
||||
BusinessHours: arg.BusinessHours,
|
||||
})
|
||||
|
||||
if arg.LocationType == LocationTypeCulinary && arg.RestaurantMenu.Valid {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user