Files
Crussell/backend/handlers/admin/custom_services.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

539 lines
16 KiB
Go

package admin
import (
"crussell/db"
"crussell/internal/validators"
"crussell/mw"
"database/sql"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
)
type CustomService struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Price float64 `json:"price"`
DurationMinutes int `json:"duration_minutes"`
MinimumAgeRequired int `json:"minimum_age_required"`
Notes *string `json:"notes,omitempty"`
CreatedAt time.Time `json:"created_at"`
CreatedBy *string `json:"created_by,omitempty"`
UsageCount int `json:"usage_count"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
}
type CreateCustomServiceRequest struct {
Name string `json:"name" validate:"required,min=1,max=100"`
Description *string `json:"description,omitempty" validate:"omitempty,max=1000"`
Price float64 `json:"price" validate:"required,gt=0"`
DurationMinutes int `json:"duration_minutes" validate:"required,gt=0,lte=480"`
MinimumAgeRequired int `json:"minimum_age_required"`
Notes *string `json:"notes,omitempty"`
}
type UpdateCustomServiceRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"`
Description *string `json:"description,omitempty" validate:"omitempty,max=1000"`
Price *float64 `json:"price,omitempty" validate:"omitempty,gt=0"`
DurationMinutes *int `json:"duration_minutes,omitempty" validate:"omitempty,gt=0,lte=480"`
MinimumAgeRequired *int `json:"minimum_age_required,omitempty"`
Notes *string `json:"notes,omitempty"`
}
type CustomServiceListResponse struct {
Services []CustomService `json:"services"`
Total int64 `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
NextCursor *string `json:"next_cursor,omitempty"`
}
// parseCursor splits a "createdAt|id" cursor string into its components.
func GetCustomServices(w http.ResponseWriter, r *http.Request) {
perPageStr := r.URL.Query().Get("per_page")
q := r.URL.Query().Get("q")
popularStr := r.URL.Query().Get("popular")
cursorStr := r.URL.Query().Get("cursor")
perPage := 20
if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
perPage = pp
}
if popularStr != "" {
n, err := strconv.Atoi(popularStr)
if err != nil || n <= 0 || n > 20 {
n = 3
}
rows, err := db.Conn.Query(r.Context(), `
SELECT id, name, price, duration_minutes, minimum_age_required, created_at, usage_count
FROM custom_services
WHERE usage_count > 0
ORDER BY usage_count DESC, last_used_at DESC NULLS LAST
LIMIT $1
`, n)
if err != nil {
http.Error(w, "Failed to fetch custom services: "+err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var services []CustomService
for rows.Next() {
var cs CustomService
if err := rows.Scan(&cs.ID, &cs.Name, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, &cs.CreatedAt, &cs.UsageCount); err != nil {
continue
}
services = append(services, cs)
}
if err := rows.Err(); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if services == nil {
services = []CustomService{}
}
json.NewEncoder(w).Encode(services)
return
}
var dataQuery string
var dataArgs []interface{}
if q != "" {
dataQuery = `
SELECT id, name, price, duration_minutes, minimum_age_required, created_at, usage_count
FROM custom_services
WHERE name ILIKE $1 OR description ILIKE $1
`
dataArgs = []interface{}{"%" + q + "%"}
dataQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(dataArgs)+1)
dataArgs = append(dataArgs, perPage+1)
} else {
dataQuery = `
SELECT id, name, price, duration_minutes, minimum_age_required, created_at, usage_count
FROM custom_services
`
// Cursor-based pagination
if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
if err != nil {
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
return
}
dataQuery += " WHERE (created_at, id) < ($1, $2)"
dataArgs = append(dataArgs, cursorCreatedAt, cursorID)
}
dataQuery += " ORDER BY created_at DESC, id DESC LIMIT $" + strconv.Itoa(len(dataArgs)+1)
dataArgs = append(dataArgs, perPage+1)
}
rows, err := db.Conn.Query(r.Context(), dataQuery, dataArgs...)
if err != nil {
http.Error(w, "Failed to fetch custom services: "+err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var services []CustomService
var total int64
for rows.Next() {
var cs CustomService
if err := rows.Scan(&cs.ID, &cs.Name, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, &cs.CreatedAt, &cs.UsageCount); err != nil {
continue
}
services = append(services, cs)
}
if err := rows.Err(); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Run count query ONLY after consuming the data query result set,
// so pgx does not return "conn busy" on the same transaction.
if q != "" {
var countTotal int64
db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM custom_services WHERE name ILIKE $1 OR description ILIKE $1", "%"+q+"%").Scan(&countTotal)
total = countTotal
} else {
var countTotal int64
db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM custom_services").Scan(&countTotal)
total = countTotal
}
// nextCursor is set only when we fetched perPage+1 items, proving a next page exists.
var nextCursor *string
if len(services) > perPage {
services = services[:perPage]
last := services[len(services)-1]
cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID
nextCursor = &cursor
}
if services == nil {
services = []CustomService{}
}
json.NewEncoder(w).Encode(CustomServiceListResponse{
Services: services,
Total: total,
PerPage: perPage,
NextCursor: nextCursor,
})
}
func CreateCustomService(w http.ResponseWriter, r *http.Request) {
var req CreateCustomServiceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.Name == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
return
}
if req.Price <= 0 {
http.Error(w, "Price must be greater than 0", http.StatusBadRequest)
return
}
if req.DurationMinutes <= 0 || req.DurationMinutes > 480 {
http.Error(w, "Duration must be between 1 and 480 minutes", http.StatusBadRequest)
return
}
if req.MinimumAgeRequired < 0 || req.MinimumAgeRequired > 100 {
http.Error(w, "Minimum age must be between 0 and 100", http.StatusBadRequest)
return
}
var createdBy *string
if userID, ok := r.Context().Value(mw.UserIDKey).(string); ok {
createdBy = &userID
}
query := `
INSERT INTO custom_services (name, description, price, duration_minutes, minimum_age_required, notes, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, description, price, duration_minutes, minimum_age_required, notes, created_at, created_by, usage_count, last_used_at
`
var cs CustomService
var desc, notes, createdByDB sql.NullString
var lastUsedAt sql.NullTime
err := db.Conn.QueryRow(r.Context(), query, req.Name, req.Description, req.Price, req.DurationMinutes, req.MinimumAgeRequired, req.Notes, createdBy).Scan(
&cs.ID, &cs.Name, &desc, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, &notes, &cs.CreatedAt, &createdByDB, &cs.UsageCount, &lastUsedAt,
)
if err != nil {
http.Error(w, "Failed to create custom service: "+err.Error(), http.StatusInternalServerError)
return
}
if desc.Valid {
cs.Description = &desc.String
}
if notes.Valid {
cs.Notes = &notes.String
}
if createdByDB.Valid {
cs.CreatedBy = &createdByDB.String
}
if lastUsedAt.Valid {
cs.LastUsedAt = &lastUsedAt.Time
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(cs)
}
func GetCustomService(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" || !validators.IsValidID(id) {
http.Error(w, "Custom service not found", http.StatusNotFound)
return
}
var cs CustomService
var desc, notes, createdBy sql.NullString
var lastUsedAt sql.NullTime
err := db.Conn.QueryRow(r.Context(), `
SELECT id, name, description, price, duration_minutes, minimum_age_required, notes, created_at, created_by, usage_count, last_used_at
FROM custom_services WHERE id = $1
`, id).Scan(&cs.ID, &cs.Name, &desc, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, &notes, &cs.CreatedAt, &createdBy, &cs.UsageCount, &lastUsedAt)
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Custom service not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Failed to fetch custom service: "+err.Error(), http.StatusInternalServerError)
return
}
if desc.Valid {
cs.Description = &desc.String
}
if notes.Valid {
cs.Notes = &notes.String
}
if createdBy.Valid {
cs.CreatedBy = &createdBy.String
}
if lastUsedAt.Valid {
cs.LastUsedAt = &lastUsedAt.Time
}
json.NewEncoder(w).Encode(cs)
}
func UpdateCustomService(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" || !validators.IsValidID(id) {
http.Error(w, "Custom service not found", http.StatusNotFound)
return
}
var req UpdateCustomServiceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
updates := make(map[string]interface{})
if req.Name != nil {
updates["name"] = *req.Name
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.Price != nil {
updates["price"] = *req.Price
}
if req.DurationMinutes != nil {
updates["duration_minutes"] = *req.DurationMinutes
}
if req.MinimumAgeRequired != nil {
updates["minimum_age_required"] = *req.MinimumAgeRequired
}
if req.Notes != nil {
updates["notes"] = *req.Notes
}
if len(updates) == 0 {
http.Error(w, "No fields to update", http.StatusBadRequest)
return
}
// Whitelist validation: only allow known column names to prevent SQL injection
// via dynamic map keys used as column identifiers.
var allowedCustomServiceFields = map[string]bool{
"name": true,
"description": true,
"price": true,
"duration_minutes": true,
"minimum_age_required": true,
"notes": true,
}
for field := range updates {
if !allowedCustomServiceFields[field] {
http.Error(w, "Invalid field: "+field, http.StatusBadRequest)
return
}
}
setClauses := make([]string, 0, len(updates))
args := make([]interface{}, 0, len(updates)+1)
argIdx := 1
for field, val := range updates {
setClauses = append(setClauses, field+" = $"+strconv.Itoa(argIdx))
args = append(args, val)
argIdx++
}
args = append(args, id)
query := "UPDATE custom_services SET " + joinStrings(setClauses, ", ") + " WHERE id = $" + strconv.Itoa(argIdx)
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
result, err := tx.Exec(r.Context(), query, args...)
if err != nil {
http.Error(w, "Failed to update custom service: "+err.Error(), http.StatusInternalServerError)
return
}
if result.RowsAffected() == 0 {
http.Error(w, "Custom service not found", http.StatusNotFound)
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"message": "Custom service updated"})
}
func PromoteCustomService(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" || !validators.IsValidID(id) {
http.Error(w, "Custom service not found", http.StatusNotFound)
return
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "Failed to start transaction", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
var name, desc, notes sql.NullString
var price float64
var durationMinutes int
var minimumAgeRequired int
var createdBy sql.NullString
err = tx.QueryRow(r.Context(), `
SELECT name, description, price, duration_minutes, minimum_age_required, notes, created_by
FROM custom_services WHERE id = $1
`, id).Scan(&name, &desc, &price, &durationMinutes, &minimumAgeRequired, &notes, &createdBy)
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Custom service not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Failed to fetch custom service: "+err.Error(), http.StatusInternalServerError)
return
}
var newServiceID string
err = tx.QueryRow(r.Context(), `
INSERT INTO services (name, description, price, duration_minutes, minimum_age_required, created_by)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, name.String, desc, price, durationMinutes, minimumAgeRequired, createdBy).Scan(&newServiceID)
if err != nil {
if err.Error() == "pq: duplicate key value violates unique constraint" || err.Error() == "duplicate key value violates unique constraint" {
http.Error(w, "A service with this name already exists", http.StatusConflict)
return
}
http.Error(w, "Failed to create service: "+err.Error(), http.StatusInternalServerError)
return
}
_, err = tx.Exec(r.Context(), `
INSERT INTO booking_services (booking_id, service_id, override_price, override_duration_minutes)
SELECT booking_id, $1, override_price, override_duration_minutes
FROM booking_custom_services WHERE custom_service_id = $2
`, newServiceID, id)
if err != nil {
http.Error(w, "Failed to migrate booking references: "+err.Error(), http.StatusInternalServerError)
return
}
_, err = tx.Exec(r.Context(), `DELETE FROM booking_custom_services WHERE custom_service_id = $1`, id)
if err != nil {
http.Error(w, "Failed to clean up booking references: "+err.Error(), http.StatusInternalServerError)
return
}
_, err = tx.Exec(r.Context(), `DELETE FROM custom_services WHERE id = $1`, id)
if err != nil {
http.Error(w, "Failed to delete custom service: "+err.Error(), http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Failed to commit transaction", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{
"message": "Custom service promoted to regular service",
"new_service_id": newServiceID,
"custom_service_id": id,
})
}
func DeleteCustomService(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" || !validators.IsValidID(id) {
http.Error(w, "Custom service not found", http.StatusNotFound)
return
}
var usageCount int
err := db.Conn.QueryRow(r.Context(), `SELECT usage_count FROM custom_services WHERE id = $1`, id).Scan(&usageCount)
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Custom service not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Failed to check usage: "+err.Error(), http.StatusInternalServerError)
return
}
if usageCount > 0 {
http.Error(w, "Cannot delete: this custom service has been used in bookings. Promote it first.", http.StatusConflict)
return
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
result, err := tx.Exec(r.Context(), `DELETE FROM custom_services WHERE id = $1`, id)
if err != nil {
http.Error(w, "Failed to delete custom service: "+err.Error(), http.StatusInternalServerError)
return
}
if result.RowsAffected() == 0 {
http.Error(w, "Custom service not found", http.StatusNotFound)
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"message": "Custom service deleted"})
}
func joinStrings(strs []string, sep string) string {
if len(strs) == 0 {
return ""
}
result := strs[0]
for _, s := range strs[1:] {
result += sep + s
}
return result
}