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:
2026-08-22 00:34:49 +01:00
parent 8384197ef0
commit 3c0c4dd962
3 changed files with 69 additions and 1 deletions
+67
View File
@@ -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 // ServicesHandler returns all services from the database
// For non-admin logged-in users, filters based on age and patch test eligibility // For non-admin logged-in users, filters based on age and patch test eligibility
func ServicesHandler(w http.ResponseWriter, r *http.Request) { func ServicesHandler(w http.ResponseWriter, r *http.Request) {
+1
View File
@@ -262,6 +262,7 @@ func main() {
r.Use(mw.RateLimit(120, time.Minute)) r.Use(mw.RateLimit(120, time.Minute))
r.Use(mw.OptionalAuth) r.Use(mw.OptionalAuth)
r.Get("/services", services.ServicesHandler) r.Get("/services", services.ServicesHandler)
r.Get("/services/popular", services.PopularServicesHandler)
r.Get("/services/eligible-for/{user_id}", services.ServicesEligibleForUserHandler) r.Get("/services/eligible-for/{user_id}", services.ServicesEligibleForUserHandler)
}) })
+1 -1
View File
@@ -23,7 +23,7 @@
async function fetchServices() { async function fetchServices() {
servicesLoading = true; servicesLoading = true;
try { try {
const response = await apiFetch('/api/services', { const response = await apiFetch('/api/services/popular', {
method: 'GET', method: 'GET',
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });