diff --git a/api/reviews.go b/api/reviews.go index 592b829..167cba7 100755 --- a/api/reviews.go +++ b/api/reviews.go @@ -260,3 +260,82 @@ func (server *Server) getUserReviewByLocation(ctx *gin.Context) { Images: images, }) } + +type getLocationReviewGradesReq struct { + LocationID int32 `uri:"location_id" binding:"required"` +} + +type locationReviewGradesGroup struct { + AvgTasteGrade *float64 `json:"avg_taste_grade"` + TasteGradeCount int64 `json:"taste_grade_count"` + AvgComfortGrade *float64 `json:"avg_comfort_grade"` + ComfortGradeCount int64 `json:"comfort_grade_count"` + AvgCleanlinessGrade *float64 `json:"avg_cleanliness_grade"` + CleanlinessGradeCount int64 `json:"cleanliness_grade_count"` + AvgServiceGrade *float64 `json:"avg_service_grade"` + ServiceGradeCount int64 `json:"service_grade_count"` + AvgFacilitiesGrade *float64 `json:"avg_facilities_grade"` + FacilitiesGradeCount int64 `json:"facilities_grade_count"` +} + +type getLocationReviewGradesRes struct { + CriticsGrades locationReviewGradesGroup `json:"critics_grades"` + UsersGrades locationReviewGradesGroup `json:"users_grades"` +} + +func nullableFloat(v pgtype.Float8) *float64 { + if !v.Valid { + return nil + } + return &v.Float64 +} + +// @Summary Get aggregated review grades for a location +// @Tags reviews +// @Produce json +// @Param location_id path int true "Location ID" +// @Success 200 {object} getLocationReviewGradesRes +// @Failure 400 {object} map[string]any +// @Failure 500 {object} map[string]any +// @Router /location/{location_id}/reviews/grades [get] +func (server *Server) getLocationReviewGrades(ctx *gin.Context) { + var req getLocationReviewGradesReq + + if err := ctx.ShouldBindUri(&req); err != nil { + ctx.JSON(http.StatusBadRequest, ValidationErrorResponse(err)) + return + } + + grades, err := server.Store.GetLocationReviewGrades(ctx, req.LocationID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, ErrorResponse(err, "Something went wrong while trying to retrieve review grades")) + return + } + + ctx.JSON(http.StatusOK, getLocationReviewGradesRes{ + CriticsGrades: locationReviewGradesGroup{ + AvgTasteGrade: nullableFloat(grades.CriticAvgTasteGrade), + TasteGradeCount: grades.CriticTasteGradeCount, + AvgComfortGrade: nullableFloat(grades.CriticAvgComfortGrade), + ComfortGradeCount: grades.CriticComfortGradeCount, + AvgCleanlinessGrade: nullableFloat(grades.CriticAvgCleanlinessGrade), + CleanlinessGradeCount: grades.CriticCleanlinessGradeCount, + AvgServiceGrade: nullableFloat(grades.CriticAvgServiceGrade), + ServiceGradeCount: grades.CriticServiceGradeCount, + AvgFacilitiesGrade: nullableFloat(grades.CriticAvgFacilitiesGrade), + FacilitiesGradeCount: grades.CriticFacilitiesGradeCount, + }, + UsersGrades: locationReviewGradesGroup{ + AvgTasteGrade: nullableFloat(grades.UserAvgTasteGrade), + TasteGradeCount: grades.UserTasteGradeCount, + AvgComfortGrade: nullableFloat(grades.UserAvgComfortGrade), + ComfortGradeCount: grades.UserComfortGradeCount, + AvgCleanlinessGrade: nullableFloat(grades.UserAvgCleanlinessGrade), + CleanlinessGradeCount: grades.UserCleanlinessGradeCount, + AvgServiceGrade: nullableFloat(grades.UserAvgServiceGrade), + ServiceGradeCount: grades.UserServiceGradeCount, + AvgFacilitiesGrade: nullableFloat(grades.UserAvgFacilitiesGrade), + FacilitiesGradeCount: grades.UserFacilitiesGradeCount, + }, + }) +} diff --git a/api/server.go b/api/server.go index 99eb88d..85c52f7 100755 --- a/api/server.go +++ b/api/server.go @@ -91,6 +91,7 @@ func (server *Server) getRoutes() { 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("/location/grades/:location_id", server.getLocationReviewGrades) router.GET("/locations/search", server.searchLocations) router.POST("/location/:location_id/visit", server.recordLocationVisit) diff --git a/db/.DS_Store b/db/.DS_Store index e6bb1ea..7ceb2b9 100644 Binary files a/db/.DS_Store and b/db/.DS_Store differ diff --git a/db/mock/store.go b/db/mock/store.go index 421d344..a7e75fd 100755 --- a/db/mock/store.go +++ b/db/mock/store.go @@ -338,6 +338,21 @@ func (mr *MockStoreMockRecorder) GetLocationLifetimeVisits(ctx, locationID any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLocationLifetimeVisits", reflect.TypeOf((*MockStore)(nil).GetLocationLifetimeVisits), ctx, locationID) } +// GetLocationReviewGrades mocks base method. +func (m *MockStore) GetLocationReviewGrades(ctx context.Context, locationID int32) (db.GetLocationReviewGradesRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLocationReviewGrades", ctx, locationID) + ret0, _ := ret[0].(db.GetLocationReviewGradesRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLocationReviewGrades indicates an expected call of GetLocationReviewGrades. +func (mr *MockStoreMockRecorder) GetLocationReviewGrades(ctx, locationID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLocationReviewGrades", reflect.TypeOf((*MockStore)(nil).GetLocationReviewGrades), ctx, locationID) +} + // GetLocationTag mocks base method. func (m *MockStore) GetLocationTag(ctx context.Context, targetID int32) ([]string, error) { m.ctrl.T.Helper() diff --git a/db/repository/reviews.go b/db/repository/reviews.go index f14286c..9c521da 100755 --- a/db/repository/reviews.go +++ b/db/repository/reviews.go @@ -146,6 +146,84 @@ func (q *Queries) GetReviewByLocationAndId(ctx context.Context, arg GetReviewByL return i, err } +type GetLocationReviewGradesRow struct { + CriticAvgTasteGrade pgtype.Float8 `json:"critic_avg_taste_grade"` + CriticTasteGradeCount int64 `json:"critic_taste_grade_count"` + CriticAvgComfortGrade pgtype.Float8 `json:"critic_avg_comfort_grade"` + CriticComfortGradeCount int64 `json:"critic_comfort_grade_count"` + CriticAvgCleanlinessGrade pgtype.Float8 `json:"critic_avg_cleanliness_grade"` + CriticCleanlinessGradeCount int64 `json:"critic_cleanliness_grade_count"` + CriticAvgServiceGrade pgtype.Float8 `json:"critic_avg_service_grade"` + CriticServiceGradeCount int64 `json:"critic_service_grade_count"` + CriticAvgFacilitiesGrade pgtype.Float8 `json:"critic_avg_facilities_grade"` + CriticFacilitiesGradeCount int64 `json:"critic_facilities_grade_count"` + + UserAvgTasteGrade pgtype.Float8 `json:"user_avg_taste_grade"` + UserTasteGradeCount int64 `json:"user_taste_grade_count"` + UserAvgComfortGrade pgtype.Float8 `json:"user_avg_comfort_grade"` + UserComfortGradeCount int64 `json:"user_comfort_grade_count"` + UserAvgCleanlinessGrade pgtype.Float8 `json:"user_avg_cleanliness_grade"` + UserCleanlinessGradeCount int64 `json:"user_cleanliness_grade_count"` + UserAvgServiceGrade pgtype.Float8 `json:"user_avg_service_grade"` + UserServiceGradeCount int64 `json:"user_service_grade_count"` + UserAvgFacilitiesGrade pgtype.Float8 `json:"user_avg_facilities_grade"` + UserFacilitiesGradeCount int64 `json:"user_facilities_grade_count"` +} + +const getLocationReviewGrades = ` +SELECT + AVG(re.taste_grade) FILTER (WHERE re.is_from_critic = true)::float8 AS critic_avg_taste_grade, + COUNT(re.taste_grade) FILTER (WHERE re.is_from_critic = true) AS critic_taste_grade_count, + AVG(re.comfort_grade) FILTER (WHERE re.is_from_critic = true)::float8 AS critic_avg_comfort_grade, + COUNT(re.comfort_grade) FILTER (WHERE re.is_from_critic = true) AS critic_comfort_grade_count, + AVG(re.cleanliness_grade) FILTER (WHERE re.is_from_critic = true)::float8 AS critic_avg_cleanliness_grade, + COUNT(re.cleanliness_grade) FILTER (WHERE re.is_from_critic = true) AS critic_cleanliness_grade_count, + AVG(re.service_grade) FILTER (WHERE re.is_from_critic = true)::float8 AS critic_avg_service_grade, + COUNT(re.service_grade) FILTER (WHERE re.is_from_critic = true) AS critic_service_grade_count, + AVG(re.facilities_grade) FILTER (WHERE re.is_from_critic = true)::float8 AS critic_avg_facilities_grade, + COUNT(re.facilities_grade) FILTER (WHERE re.is_from_critic = true) AS critic_facilities_grade_count, + AVG(re.taste_grade) FILTER (WHERE re.is_from_critic = false)::float8 AS user_avg_taste_grade, + COUNT(re.taste_grade) FILTER (WHERE re.is_from_critic = false) AS user_taste_grade_count, + AVG(re.comfort_grade) FILTER (WHERE re.is_from_critic = false)::float8 AS user_avg_comfort_grade, + COUNT(re.comfort_grade) FILTER (WHERE re.is_from_critic = false) AS user_comfort_grade_count, + AVG(re.cleanliness_grade) FILTER (WHERE re.is_from_critic = false)::float8 AS user_avg_cleanliness_grade, + COUNT(re.cleanliness_grade) FILTER (WHERE re.is_from_critic = false) AS user_cleanliness_grade_count, + AVG(re.service_grade) FILTER (WHERE re.is_from_critic = false)::float8 AS user_avg_service_grade, + COUNT(re.service_grade) FILTER (WHERE re.is_from_critic = false) AS user_service_grade_count, + AVG(re.facilities_grade) FILTER (WHERE re.is_from_critic = false)::float8 AS user_avg_facilities_grade, + COUNT(re.facilities_grade) FILTER (WHERE re.is_from_critic = false) AS user_facilities_grade_count +FROM reviews re +WHERE re.location_id = $1 AND re.is_hided = false; +` + +func (q *Queries) GetLocationReviewGrades(ctx context.Context, locationID int32) (GetLocationReviewGradesRow, error) { + row := q.db.QueryRow(ctx, getLocationReviewGrades, locationID) + var i GetLocationReviewGradesRow + err := row.Scan( + &i.CriticAvgTasteGrade, + &i.CriticTasteGradeCount, + &i.CriticAvgComfortGrade, + &i.CriticComfortGradeCount, + &i.CriticAvgCleanlinessGrade, + &i.CriticCleanlinessGradeCount, + &i.CriticAvgServiceGrade, + &i.CriticServiceGradeCount, + &i.CriticAvgFacilitiesGrade, + &i.CriticFacilitiesGradeCount, + &i.UserAvgTasteGrade, + &i.UserTasteGradeCount, + &i.UserAvgComfortGrade, + &i.UserComfortGradeCount, + &i.UserAvgCleanlinessGrade, + &i.UserCleanlinessGradeCount, + &i.UserAvgServiceGrade, + &i.UserServiceGradeCount, + &i.UserAvgFacilitiesGrade, + &i.UserFacilitiesGradeCount, + ) + return i, err +} + func (q *Queries) GetListLocationReviews(ctx context.Context, arg GetListLocationReviewsParams) ([]GetListLocationReviewsRow, error) { rows, err := q.db.Query(ctx, getListLocationReviews, arg.LocationID, arg.IsCritics, arg.Limit, arg.Offset) if err != nil { diff --git a/db/repository/store.go b/db/repository/store.go index 98ef28c..2aa02f9 100755 --- a/db/repository/store.go +++ b/db/repository/store.go @@ -29,6 +29,7 @@ type Store interface { RecordLocationVisit(ctx context.Context, locationID int32) error GetLocationLifetimeVisits(ctx context.Context, locationID int32) (int64, error) GetTrendingLocations(ctx context.Context, arg GetTrendingLocationsParams) ([]GetTrendingLocationsRow, error) + GetLocationReviewGrades(ctx context.Context, locationID int32) (GetLocationReviewGradesRow, error) } type SQLStore struct { diff --git a/docs/docs.go b/docs/docs.go index 5c335bc..0e4ca8c 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -258,6 +258,88 @@ const docTemplate = `{ } } }, + "/location/{location_id}/reviews/grades": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "reviews" + ], + "summary": "Get aggregated review grades for a location", + "parameters": [ + { + "type": "integer", + "description": "Location ID", + "name": "location_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.getLocationReviewGradesRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/location/{location_id}/visit": { + "post": { + "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.", + "produces": [ + "application/json" + ], + "tags": [ + "locations" + ], + "summary": "Record a page visit for a location", + "parameters": [ + { + "type": "integer", + "description": "Location ID", + "name": "location_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/locations": { "get": { "produces": [ @@ -356,6 +438,12 @@ const docTemplate = `{ "name": "amenities", "in": "formData" }, + { + "type": "string", + "description": "Business hours JSON: [{\\", + "name": "business_hours", + "in": "formData" + }, { "type": "string", "description": "Google Maps link", @@ -536,6 +624,104 @@ const docTemplate = `{ } } }, + "/locations/trending": { + "get": { + "description": "Ranks approved, non-deleted locations by total page-visits in the trailing window.", + "produces": [ + "application/json" + ], + "tags": [ + "locations" + ], + "summary": "Trending locations", + "parameters": [ + { + "type": "string", + "description": "Window: week | month | 3month | semester | year", + "name": "window", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Page (min 1)", + "name": "page", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Page size (1-50)", + "name": "page_size", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/menu-items": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "menu" + ], + "summary": "Get menu items", + "parameters": [ + { + "type": "integer", + "description": "Filter by location ID (omit for all)", + "name": "location_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/db.GetMenuItemsRow" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/news-events": { "get": { "produces": [ @@ -1187,6 +1373,84 @@ const docTemplate = `{ "type": "string" } } + }, + "api.getLocationReviewGradesRes": { + "type": "object", + "properties": { + "critics_grades": { + "$ref": "#/definitions/api.locationReviewGradesGroup" + }, + "users_grades": { + "$ref": "#/definitions/api.locationReviewGradesGroup" + } + } + }, + "api.locationReviewGradesGroup": { + "type": "object", + "properties": { + "avg_cleanliness_grade": { + "type": "number" + }, + "avg_comfort_grade": { + "type": "number" + }, + "avg_facilities_grade": { + "type": "number" + }, + "avg_service_grade": { + "type": "number" + }, + "avg_taste_grade": { + "type": "number" + }, + "cleanliness_grade_count": { + "type": "integer" + }, + "comfort_grade_count": { + "type": "integer" + }, + "facilities_grade_count": { + "type": "integer" + }, + "service_grade_count": { + "type": "integer" + }, + "taste_grade_count": { + "type": "integer" + } + } + }, + "db.GetMenuItemsRow": { + "type": "object", + "properties": { + "avg_score": { + "type": "number" + }, + "category": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_available": { + "type": "boolean" + }, + "location_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "price": { + "type": "integer" + }, + "submitted_by": { + "type": "integer" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index f179ccb..9cce5fb 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -252,6 +252,88 @@ } } }, + "/location/{location_id}/reviews/grades": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "reviews" + ], + "summary": "Get aggregated review grades for a location", + "parameters": [ + { + "type": "integer", + "description": "Location ID", + "name": "location_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/api.getLocationReviewGradesRes" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/location/{location_id}/visit": { + "post": { + "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.", + "produces": [ + "application/json" + ], + "tags": [ + "locations" + ], + "summary": "Record a page visit for a location", + "parameters": [ + { + "type": "integer", + "description": "Location ID", + "name": "location_id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/locations": { "get": { "produces": [ @@ -350,6 +432,12 @@ "name": "amenities", "in": "formData" }, + { + "type": "string", + "description": "Business hours JSON: [{\\", + "name": "business_hours", + "in": "formData" + }, { "type": "string", "description": "Google Maps link", @@ -530,6 +618,104 @@ } } }, + "/locations/trending": { + "get": { + "description": "Ranks approved, non-deleted locations by total page-visits in the trailing window.", + "produces": [ + "application/json" + ], + "tags": [ + "locations" + ], + "summary": "Trending locations", + "parameters": [ + { + "type": "string", + "description": "Window: week | month | 3month | semester | year", + "name": "window", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Page (min 1)", + "name": "page", + "in": "query", + "required": true + }, + { + "type": "integer", + "description": "Page size (1-50)", + "name": "page_size", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/menu-items": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "menu" + ], + "summary": "Get menu items", + "parameters": [ + { + "type": "integer", + "description": "Filter by location ID (omit for all)", + "name": "location_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/db.GetMenuItemsRow" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, "/news-events": { "get": { "produces": [ @@ -1181,6 +1367,84 @@ "type": "string" } } + }, + "api.getLocationReviewGradesRes": { + "type": "object", + "properties": { + "critics_grades": { + "$ref": "#/definitions/api.locationReviewGradesGroup" + }, + "users_grades": { + "$ref": "#/definitions/api.locationReviewGradesGroup" + } + } + }, + "api.locationReviewGradesGroup": { + "type": "object", + "properties": { + "avg_cleanliness_grade": { + "type": "number" + }, + "avg_comfort_grade": { + "type": "number" + }, + "avg_facilities_grade": { + "type": "number" + }, + "avg_service_grade": { + "type": "number" + }, + "avg_taste_grade": { + "type": "number" + }, + "cleanliness_grade_count": { + "type": "integer" + }, + "comfort_grade_count": { + "type": "integer" + }, + "facilities_grade_count": { + "type": "integer" + }, + "service_grade_count": { + "type": "integer" + }, + "taste_grade_count": { + "type": "integer" + } + } + }, + "db.GetMenuItemsRow": { + "type": "object", + "properties": { + "avg_score": { + "type": "number" + }, + "category": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_available": { + "type": "boolean" + }, + "location_id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "price": { + "type": "integer" + }, + "submitted_by": { + "type": "integer" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 02dc102..e3ba376 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -58,6 +58,57 @@ definitions: - password - username type: object + api.getLocationReviewGradesRes: + properties: + critics_grades: + $ref: '#/definitions/api.locationReviewGradesGroup' + users_grades: + $ref: '#/definitions/api.locationReviewGradesGroup' + type: object + api.locationReviewGradesGroup: + properties: + avg_cleanliness_grade: + type: number + avg_comfort_grade: + type: number + avg_facilities_grade: + type: number + avg_service_grade: + type: number + avg_taste_grade: + type: number + cleanliness_grade_count: + type: integer + comfort_grade_count: + type: integer + facilities_grade_count: + type: integer + service_grade_count: + type: integer + taste_grade_count: + type: integer + type: object + db.GetMenuItemsRow: + properties: + avg_score: + type: number + category: + type: string + description: + type: string + id: + type: integer + is_available: + type: boolean + location_id: + type: integer + name: + type: string + price: + type: integer + submitted_by: + type: integer + type: object host: localhost:8888 info: contact: {} @@ -164,6 +215,62 @@ paths: summary: Get a single review tags: - reviews + /location/{location_id}/reviews/grades: + get: + parameters: + - description: Location ID + in: path + name: location_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/api.getLocationReviewGradesRes' + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Get aggregated review grades for a location + tags: + - reviews + /location/{location_id}/visit: + post: + 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. + parameters: + - description: Location ID + in: path + name: location_id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Record a page visit for a location + tags: + - locations /location/reviews: get: parameters: @@ -293,6 +400,10 @@ paths: in: formData name: amenities type: string + - description: 'Business hours JSON: [{\' + in: formData + name: business_hours + type: string - description: Google Maps link in: formData name: google_maps_link @@ -416,6 +527,73 @@ paths: summary: Top-rated locations tags: - locations + /locations/trending: + get: + description: Ranks approved, non-deleted locations by total page-visits in the + trailing window. + parameters: + - description: 'Window: week | month | 3month | semester | year' + in: query + name: window + required: true + type: string + - description: Page (min 1) + in: query + name: page + required: true + type: integer + - description: Page size (1-50) + in: query + name: page_size + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + additionalProperties: true + type: object + type: array + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: Trending locations + tags: + - locations + /menu-items: + get: + parameters: + - description: Filter by location ID (omit for all) + in: query + name: location_id + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/db.GetMenuItemsRow' + type: array + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + summary: Get menu items + tags: + - menu /news-events: get: parameters: