From 305beabacb6615da5f4c8b0577f3131c2ec542a3 Mon Sep 17 00:00:00 2001 From: goro Date: Sun, 14 Jun 2026 05:52:04 +0300 Subject: [PATCH] update --- .gitignore | 4 +- api/location.go | 161 +++++++++++++++- api/menu_items.go | 45 +++++ api/server.go | 5 + api/test/location_test.go | 1 - ...00014_create_menu_items_and_reviews.up.sql | 2 +- ..._locations_open_time_in_locations.down.sql | 1 + ...ed_locations_open_time_in_locations.up.sql | 1 + ...00016_rebuld_location_page_visits.down.sql | 20 ++ .../000016_rebuld_location_page_visits.up.sql | 30 +++ db/mock/store.go | 73 +++++++ db/queries/locations.sql | 8 +- db/repository/location_visits.go | 178 ++++++++++++++++++ db/repository/locations.go | 17 +- db/repository/locations.sql.go | 22 ++- db/repository/menu_items.go | 60 ++++++ db/repository/models.go | 110 +++++++++-- db/repository/querier.go | 1 + db/repository/store.go | 5 + db/repository/tx_location.go | 2 + 20 files changed, 722 insertions(+), 24 deletions(-) create mode 100644 api/menu_items.go create mode 100644 db/migrations/000015_added_locations_open_time_in_locations.down.sql create mode 100644 db/migrations/000015_added_locations_open_time_in_locations.up.sql create mode 100644 db/migrations/000016_rebuld_location_page_visits.down.sql create mode 100644 db/migrations/000016_rebuld_location_page_visits.up.sql create mode 100644 db/repository/location_visits.go diff --git a/.gitignore b/.gitignore index da422b5..0a04ca7 100755 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ *.env data.ms tmp -public \ No newline at end of file +public +**/.DS_Store +.idea \ No newline at end of file diff --git a/api/location.go b/api/location.go index 58f4025..2b50279 100755 --- a/api/location.go +++ b/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) +} diff --git a/api/menu_items.go b/api/menu_items.go new file mode 100644 index 0000000..ae83bee --- /dev/null +++ b/api/menu_items.go @@ -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) +} diff --git a/api/server.go b/api/server.go index 5bf3875..99eb88d 100755 --- a/api/server.go +++ b/api/server.go @@ -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) diff --git a/api/test/location_test.go b/api/test/location_test.go index 5329b76..49516c3 100755 --- a/api/test/location_test.go +++ b/api/test/location_test.go @@ -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, diff --git a/db/migrations/000014_create_menu_items_and_reviews.up.sql b/db/migrations/000014_create_menu_items_and_reviews.up.sql index 5150d58..07eb7cd 100644 --- a/db/migrations/000014_create_menu_items_and_reviews.up.sql +++ b/db/migrations/000014_create_menu_items_and_reviews.up.sql @@ -56,4 +56,4 @@ CREATE TABLE menu_item_reviews ( "updated_at" timestamp default(now()), UNIQUE("menu_item_id", "submitted_by") ); - + diff --git a/db/migrations/000015_added_locations_open_time_in_locations.down.sql b/db/migrations/000015_added_locations_open_time_in_locations.down.sql new file mode 100644 index 0000000..2764f39 --- /dev/null +++ b/db/migrations/000015_added_locations_open_time_in_locations.down.sql @@ -0,0 +1 @@ +ALTER TABLE locations DROP COLUMN IF EXISTS business_hours; \ No newline at end of file diff --git a/db/migrations/000015_added_locations_open_time_in_locations.up.sql b/db/migrations/000015_added_locations_open_time_in_locations.up.sql new file mode 100644 index 0000000..75fc918 --- /dev/null +++ b/db/migrations/000015_added_locations_open_time_in_locations.up.sql @@ -0,0 +1 @@ +ALTER TABLE locations ADD IF NOT EXISTS business_hours jsonb; \ No newline at end of file diff --git a/db/migrations/000016_rebuld_location_page_visits.down.sql b/db/migrations/000016_rebuld_location_page_visits.down.sql new file mode 100644 index 0000000..053cdcc --- /dev/null +++ b/db/migrations/000016_rebuld_location_page_visits.down.sql @@ -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; diff --git a/db/migrations/000016_rebuld_location_page_visits.up.sql b/db/migrations/000016_rebuld_location_page_visits.up.sql new file mode 100644 index 0000000..e146609 --- /dev/null +++ b/db/migrations/000016_rebuld_location_page_visits.up.sql @@ -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; diff --git a/db/mock/store.go b/db/mock/store.go index ade02a4..421d344 100755 --- a/db/mock/store.go +++ b/db/mock/store.go @@ -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() diff --git a/db/queries/locations.sql b/db/queries/locations.sql index 2b25a14..a98b31a 100755 --- a/db/queries/locations.sql +++ b/db/queries/locations.sql @@ -15,4 +15,10 @@ approved_by IS NOT NULL; -- name: UpdateLocationThumbnail :exec UPDATE locations SET thumbnail = $1 -WHERE id = $2; \ No newline at end of file +WHERE id = $2; + +-- name: UpdateLocationBusinessHours :exec +UPDATE locations +SET business_hours = $1, + updated_at = now() +WHERE id = $2; diff --git a/db/repository/location_visits.go b/db/repository/location_visits.go new file mode 100644 index 0000000..a96f8f6 --- /dev/null +++ b/db/repository/location_visits.go @@ -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 +} diff --git a/db/repository/locations.go b/db/repository/locations.go index 0709dcd..94190f0 100755 --- a/db/repository/locations.go +++ b/db/repository/locations.go @@ -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, ) diff --git a/db/repository/locations.sql.go b/db/repository/locations.sql.go index dd99bd9..d4ee952 100755 --- a/db/repository/locations.sql.go +++ b/db/repository/locations.sql.go @@ -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 diff --git a/db/repository/menu_items.go b/db/repository/menu_items.go index b1e5675..5aff264 100644 --- a/db/repository/menu_items.go +++ b/db/repository/menu_items.go @@ -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, diff --git a/db/repository/models.go b/db/repository/models.go index dcd1ee5..9be70d3 100755 --- a/db/repository/models.go +++ b/db/repository/models.go @@ -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 { diff --git a/db/repository/querier.go b/db/repository/querier.go index 3b5ff24..0e9f14a 100755 --- a/db/repository/querier.go +++ b/db/repository/querier.go @@ -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 } diff --git a/db/repository/store.go b/db/repository/store.go index 16b5c0f..98ef28c 100755 --- a/db/repository/store.go +++ b/db/repository/store.go @@ -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 { diff --git a/db/repository/tx_location.go b/db/repository/tx_location.go index d42b435..c8e3141 100755 --- a/db/repository/tx_location.go +++ b/db/repository/tx_location.go @@ -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 {