feat: sort price list by booking popularity over last 6 months
Adds GET /api/services/popular endpoint that returns services sorted by booking count (desc) then price (desc) for ties. Prices page now fetches from this endpoint instead of the default alphabetical sort.
This commit is contained in:
@@ -262,6 +262,73 @@ func DeleteServiceHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// PopularServicesHandler returns services sorted by booking popularity (most bookings
|
||||
// in the last 6 months first), with ties broken by price (highest first).
|
||||
func PopularServicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
query := `
|
||||
SELECT s.id, s.name, s.description, s.price, s.duration_minutes,
|
||||
s.minimum_age_required, COALESCE(pt.notice_duration_hours, 0),
|
||||
COALESCE(booking_counts.cnt, 0)
|
||||
FROM services s
|
||||
LEFT JOIN patch_tests pt ON s.id = ANY(pt.service_ids)
|
||||
LEFT JOIN (
|
||||
SELECT bsvc.service_id, COUNT(*) AS cnt
|
||||
FROM booking_services bsvc
|
||||
JOIN bookings b ON b.id = bsvc.booking_id
|
||||
WHERE b.start_time >= NOW() - INTERVAL '6 months'
|
||||
GROUP BY bsvc.service_id
|
||||
) booking_counts ON s.id = booking_counts.service_id
|
||||
WHERE s.is_active = TRUE
|
||||
ORDER BY booking_counts.cnt DESC, s.price DESC, s.name
|
||||
`
|
||||
|
||||
rows, err := db.Conn.Query(r.Context(), query)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var services []ServiceResponse
|
||||
|
||||
for rows.Next() {
|
||||
var service ServiceResponse
|
||||
var bookingCount int
|
||||
|
||||
err := rows.Scan(
|
||||
&service.ID,
|
||||
&service.Name,
|
||||
&service.Description,
|
||||
&service.Price,
|
||||
&service.DurationMinutes,
|
||||
&service.MinimumAgeRequired,
|
||||
&service.PatchTestDurationHours,
|
||||
&bookingCount,
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to read service data: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
services = append(services, service)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
http.Error(w, "Error iterating over services: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
if services == nil {
|
||||
services = []ServiceResponse{}
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(services); err != nil {
|
||||
http.Error(w, "Failed to encode response: "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ServicesHandler returns all services from the database
|
||||
// For non-admin logged-in users, filters based on age and patch test eligibility
|
||||
func ServicesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -262,6 +262,7 @@ func main() {
|
||||
r.Use(mw.RateLimit(120, time.Minute))
|
||||
r.Use(mw.OptionalAuth)
|
||||
r.Get("/services", services.ServicesHandler)
|
||||
r.Get("/services/popular", services.PopularServicesHandler)
|
||||
r.Get("/services/eligible-for/{user_id}", services.ServicesEligibleForUserHandler)
|
||||
})
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
async function fetchServices() {
|
||||
servicesLoading = true;
|
||||
try {
|
||||
const response = await apiFetch('/api/services', {
|
||||
const response = await apiFetch('/api/services/popular', {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user