This commit is contained in:
goro 2026-06-14 05:52:04 +03:00
parent 67eb0a5a66
commit 305beabacb
20 changed files with 722 additions and 24 deletions

4
.gitignore vendored
View File

@ -1,4 +1,6 @@
*.env
data.ms
tmp
public
public
**/.DS_Store
.idea

View File

@ -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
View 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)
}

View File

@ -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)

View File

@ -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,

View File

@ -56,4 +56,4 @@ CREATE TABLE menu_item_reviews (
"updated_at" timestamp default(now()),
UNIQUE("menu_item_id", "submitted_by")
);

View File

@ -0,0 +1 @@
ALTER TABLE locations DROP COLUMN IF EXISTS business_hours;

View File

@ -0,0 +1 @@
ALTER TABLE locations ADD IF NOT EXISTS business_hours jsonb;

View 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;

View 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;

View File

@ -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()

View File

@ -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;

View 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
}

View File

@ -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,
)

View File

@ -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

View File

@ -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,

View File

@ -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 {

View File

@ -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
}

View File

@ -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 {

View File

@ -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 {