diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go
index fc0a40f..1ea81fe 100644
--- a/backend/handlers/bookings/manage.go
+++ b/backend/handlers/bookings/manage.go
@@ -168,57 +168,193 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
-// AdminCreateBookingForUserHandler creates a booking on behalf of a user.
-func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
- userID := chi.URLParam(r, "user_id")
- var req CreateBookingRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, "Invalid request body", http.StatusBadRequest)
+type AdminCreateBookingForUserRequest struct {
+ UserID string `json:"user_id" validate:"required"`
+ StartTime time.Time `json:"start_time" validate:"required"`
+ ServiceIDs []string `json:"service_ids" validate:"required,min=1"`
+ ServiceOverrides []ServiceOverride `json:"service_overrides,omitempty"`
+ Notes *string `json:"notes,omitempty"` // staff notes
+}
+
+func oo(w http.ResponseWriter, r *http.Request) {
+ // Admin identity (creator)
+ adminID, ok := r.Context().Value(mw.UserIDKey).(string)
+ if !ok || adminID == "" {
+ http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
- adminID, _ := r.Context().Value(mw.UserIDKey).(string)
+ var req AdminCreateBookingForUserRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ log.Printf("Failed to decode request: %v", err)
+ http.Error(w, "Invalid request", http.StatusBadRequest)
+ return
+ }
+
+ // Basic validation
+ if req.UserID == "" {
+ http.Error(w, "User ID is required", http.StatusBadRequest)
+ return
+ }
+ if req.StartTime.IsZero() {
+ http.Error(w, "Start time is required", http.StatusBadRequest)
+ return
+ }
+ if len(req.ServiceIDs) == 0 {
+ http.Error(w, "At least one service is required", http.StatusBadRequest)
+ return
+ }
+
+ // Validate overrides
+ for _, override := range req.ServiceOverrides {
+ if override.ServiceID == "" {
+ http.Error(w, "Service ID is required for overrides", http.StatusBadRequest)
+ return
+ }
+ if override.OverridePrice != nil && *override.OverridePrice < 0 {
+ http.Error(w, "Override price cannot be negative", http.StatusBadRequest)
+ return
+ }
+ if override.OverrideDurationMinutes != nil && *override.OverrideDurationMinutes <= 0 {
+ http.Error(w, "Override duration must be positive", http.StatusBadRequest)
+ return
+ }
+ }
tx, err := db.DB.Begin(r.Context())
if err != nil {
- log.Printf("Failed to begin transaction: %v", err)
+ log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
- var bookingID string
- err = tx.QueryRow(r.Context(), `
- INSERT INTO bookings (id, user_id, start_time, status, created_by)
- VALUES (generate_booking_id(), $1, $2, 'pending', $3)
- RETURNING id
- `, userID, req.StartTime, adminID).Scan(&bookingID)
+ // Create booking directly as confirmed
+ bookingQuery := `
+ INSERT INTO bookings (
+ user_id,
+ start_time,
+ status,
+ notes,
+ created_by
+ )
+ VALUES ($1, $2, 'confirmed', $3, $4)
+ RETURNING id, user_id, start_time, status, notes, created_at, updated_at, created_by
+ `
+
+ var booking Booking
+ booking.User = &UserSummary{}
+
+ err = tx.QueryRow(
+ r.Context(),
+ bookingQuery,
+ req.UserID,
+ req.StartTime,
+ req.Notes,
+ adminID,
+ ).Scan(
+ &booking.ID,
+ &booking.User.ID,
+ &booking.StartTime,
+ &booking.Status,
+ &booking.Notes,
+ &booking.CreatedAt,
+ &booking.UpdatedAt,
+ &booking.CreatedBy,
+ )
+
if err != nil {
- log.Printf("Failed to create booking: %v", err)
+ log.Printf("Failed to create admin booking: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
- for _, svcID := range req.ServiceIDs {
- if _, err := tx.Exec(r.Context(), `
- INSERT INTO booking_services (booking_id, service_id)
- VALUES ($1, $2)
- `, bookingID, svcID); err != nil {
- log.Printf("Failed to insert booking service: %v", err)
+ // Insert booking services
+ serviceInsertQuery := `
+ INSERT INTO booking_services (booking_id, service_id)
+ VALUES ($1, $2)
+ `
+
+ for _, serviceID := range req.ServiceIDs {
+ _, err := tx.Exec(r.Context(), serviceInsertQuery, booking.ID, serviceID)
+ if err != nil {
+ log.Printf("Failed to insert booking service %s: %v", serviceID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
- if err = tx.Commit(r.Context()); err != nil {
- log.Printf("Failed to commit transaction: %v", err)
+ // Apply overrides (optional)
+ if len(req.ServiceOverrides) > 0 {
+ // Ensure overrides only reference services in this booking
+ serviceCheckQuery := `
+ SELECT COUNT(*) FROM booking_services
+ WHERE booking_id = $1 AND service_id = ANY($2)
+ `
+
+ overrideServiceIDs := make([]string, len(req.ServiceOverrides))
+ for i, o := range req.ServiceOverrides {
+ overrideServiceIDs[i] = o.ServiceID
+ }
+
+ var count int
+ err = tx.QueryRow(
+ r.Context(),
+ serviceCheckQuery,
+ booking.ID,
+ overrideServiceIDs,
+ ).Scan(&count)
+
+ if err != nil {
+ log.Printf("Failed to verify service overrides: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ if count != len(req.ServiceOverrides) {
+ http.Error(w, "One or more service overrides do not belong to this booking", http.StatusBadRequest)
+ return
+ }
+
+ overrideUpdateQuery := `
+ UPDATE booking_services
+ SET override_price = $1,
+ override_duration_minutes = $2
+ WHERE booking_id = $3 AND service_id = $4
+ `
+
+ for _, override := range req.ServiceOverrides {
+ _, err := tx.Exec(
+ r.Context(),
+ overrideUpdateQuery,
+ override.OverridePrice,
+ override.OverrideDurationMinutes,
+ booking.ID,
+ override.ServiceID,
+ )
+ if err != nil {
+ log.Printf(
+ "Failed to apply override (booking %s, service %s): %v",
+ booking.ID,
+ override.ServiceID,
+ err,
+ )
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+ }
+ }
+
+ if err := tx.Commit(r.Context()); err != nil {
+ log.Printf("Failed to commit admin booking creation: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
- if err := json.NewEncoder(w).Encode(map[string]string{"id": bookingID}); err != nil {
+ if err := json.NewEncoder(w).Encode(booking); err != nil {
log.Printf("Failed to encode response: %v", err)
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
diff --git a/backend/main.go b/backend/main.go
index a0cc0fc..579f5dd 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -128,12 +128,13 @@ func main() {
r.Route("/admin/bookings", func(r chi.Router) {
r.Get("/", bookings.GetAllAdminBookingsHandler)
+ r.Post("/", bookings.AdminCreateBookingForUserHandler)
r.Get("/search", bookings.SearchAdminBookingsHandler)
r.Get("/user/{user_id}", bookings.GetAllBookingsByUserHandler)
r.Get("/{id}", bookings.GetAdminBookingHandler)
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
- r.Post("/{id}/cancel", bookings.ConfirmBookingHandler)
+ r.Post("/{id}/cancel", bookings.ConfirmBookingHandler) // todo
})
r.Route("/admin/users", func(r chi.Router) {
diff --git a/frontend/src/lib/components/admin/BookingCreateModal.svelte b/frontend/src/lib/components/admin/BookingCreateModal.svelte
new file mode 100644
index 0000000..402a74d
--- /dev/null
+++ b/frontend/src/lib/components/admin/BookingCreateModal.svelte
@@ -0,0 +1,108 @@
+
+
+
+ Create and confirm a booking immediately while speaking with the client. +
+ +