diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go new file mode 100644 index 0000000..fc0a40f --- /dev/null +++ b/backend/handlers/bookings/manage.go @@ -0,0 +1,224 @@ +package bookings + +import ( + "crussell/db" + "crussell/mw" + "database/sql" + "encoding/json" + "log" + "net/http" + "time" + + "github.com/go-chi/chi/v5" +) + +// UserCancelBookingHandler allows an authenticated user to cancel a booking they own. +// The update is performed in a single statement with appropriate conditions. +func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + + userID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + res, err := db.DB.Exec(r.Context(), ` + UPDATE bookings + SET status = 'client_cancelled', updated_at = $1 + WHERE id = $2 AND user_id = $3 AND status IN ('pending', 'confirmed', 'in_progress') + `, time.Now(), bookingID, userID) + if err != nil { + log.Printf("Failed to cancel booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + rowsAffected := res.RowsAffected() + if rowsAffected == 0 { + http.Error(w, "Booking not cancellable", http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// AdminCancelBookingHandler allows an admin to cancel any booking. +// The update uses a status filter and checks RowsAffected for existence. +func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + + res, err := db.DB.Exec(r.Context(), ` + UPDATE bookings + SET status = 'we_cancelled', updated_at = $1 + WHERE id = $2 AND status IN ('pending', 'confirmed', 'in_progress') + `, time.Now(), bookingID) + if err != nil { + log.Printf("Failed to admin cancel booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + rowsAffected := res.RowsAffected() + if rowsAffected == 0 { + http.Error(w, "Booking not cancellable", http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// AdminListPendingBookingsHandler returns all bookings with status `pending` by delegating to the existing admin list handler. +func AdminListPendingBookingsHandler(w http.ResponseWriter, r *http.Request) { + r = r.Clone(r.Context()) + q := r.URL.Query() + q.Set("status", "pending") + r.URL.RawQuery = q.Encode() + + GetAllAdminBookingsHandler(w, r) +} + +// AdminGetInProgressBookingHandler returns the booking that is currently in progress. +// It joins the bookings table with users to populate the UserSummary in the returned Booking. +func AdminGetInProgressBookingHandler(w http.ResponseWriter, r *http.Request) { + var b Booking + var userID, fullName string + + err := db.DB.QueryRow(r.Context(), ` + SELECT + b.id, + b.start_time, + b.status, + b.notes, + b.created_at, + b.updated_at, + b.created_by, + u.id, + u.fn + FROM bookings b + LEFT JOIN users u ON b.user_id = u.id + WHERE b.status = 'in_progress' + ORDER BY b.start_time + LIMIT 1 + `).Scan( + &b.ID, + &b.StartTime, + &b.Status, + &b.Notes, + &b.CreatedAt, + &b.UpdatedAt, + &b.CreatedBy, + &userID, + &fullName, + ) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "No in-progress booking found", http.StatusNotFound) + return + } + log.Printf("Failed to fetch in-progress booking: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Populate the UserSummary field + b.User = &UserSummary{ + ID: userID, + FullName: fullName, + FirstName: "", // not available here + LastName: "", // not available here + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(b); err != nil { + log.Printf("Failed to encode booking response: %v", err) + } +} + +// AdminEditBookingHandler allows an admin to modify the start time of any booking. +// It validates the new start time and returns 404 if the booking does not exist. +func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + var req EditBookingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Basic validation: ensure the new time is not in the past + if time.Now().After(req.StartTime) { + http.Error(w, "Start time must be in the future", http.StatusBadRequest) + return + } + + res, err := db.DB.Exec(r.Context(), ` + UPDATE bookings + SET start_time = $1, updated_at = $2 + WHERE id = $3 + `, req.StartTime, time.Now(), bookingID) + if err != nil { + log.Printf("Failed to edit booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + rowsAffected := res.RowsAffected() + if rowsAffected == 0 { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + + 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) + return + } + + adminID, _ := r.Context().Value(mw.UserIDKey).(string) + + tx, err := db.DB.Begin(r.Context()) + if err != nil { + log.Printf("Failed to begin 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) + if err != nil { + log.Printf("Failed to create 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) + 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) + 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 { + log.Printf("Failed to encode response: %v", err) + } +} diff --git a/backend/handlers/notifications/notifications.go b/backend/handlers/notifications/notifications.go new file mode 100644 index 0000000..b740aff --- /dev/null +++ b/backend/handlers/notifications/notifications.go @@ -0,0 +1,175 @@ +package notifications + +import ( + "crussell/db" + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" +) + +// Structs returned in JSON +type AdminNotification struct { + ID int `json:"id"` + Reason string `json:"reason"` + BookingID *int `json:"booking_id,omitempty"` + UserID *int `json:"user_id,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type AdminNotificationListResponse struct { + Notifications []AdminNotification `json:"notifications"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int `json:"total"` +} + +// GET /api/admin/notifications +func GetNotifications(w http.ResponseWriter, r *http.Request) { + // Parse query params + page := 1 + perPage := 20 + + if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 { + page = p + } + if pp, err := strconv.Atoi(r.URL.Query().Get("per_page")); err == nil && pp > 0 { + perPage = pp + } + + reasonFilter := r.URL.Query().Get("reason") + + baseQuery := ` + SELECT id, reason, booking_id, user_id, created_at + FROM admin_notifications + WHERE acknowledged_at IS NULL +` + countQuery := ` + SELECT COUNT(*) FROM admin_notifications + WHERE acknowledged_at IS NULL +` + + args := []any{} + countArgs := []any{} + param := 1 + + // Optional reason filter + if reasonFilter != "" { + baseQuery += fmt.Sprintf(" AND reason = $%d", param) + countQuery += fmt.Sprintf(" AND reason = $%d", param) + args = append(args, reasonFilter) + countArgs = append(countArgs, reasonFilter) + param++ + } + + // ORDER & pagination + baseQuery += " ORDER BY created_at DESC" + baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", param, param+1) + args = append(args, perPage, (page-1)*perPage) + + // Count + var total int + err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total) + if err != nil { + log.Printf("Failed to count notifications: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Query + rows, err := db.DB.Query(r.Context(), baseQuery, args...) + if err != nil { + log.Printf("Failed to fetch notifications: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + notifications := []AdminNotification{} + + for rows.Next() { + var n AdminNotification + var bookingID sql.NullInt32 + var userID sql.NullInt32 + + err := rows.Scan( + &n.ID, + &n.Reason, + &bookingID, + &userID, + &n.CreatedAt, + ) + if err != nil { + log.Printf("Failed to scan notification row: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if bookingID.Valid { + id := int(bookingID.Int32) + n.BookingID = &id + } + if userID.Valid { + id := int(userID.Int32) + n.UserID = &id + } + + notifications = append(notifications, n) + } + + resp := AdminNotificationListResponse{ + Notifications: notifications, + Page: page, + PerPage: perPage, + Total: total, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(resp); err != nil { + log.Printf("Failed to encode response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } +} + +func AcknowledgeNotification(w http.ResponseWriter, r *http.Request) { + idStr := chi.URLParam(r, "id") + if idStr == "" { + http.Error(w, "Notification ID is required", http.StatusBadRequest) + return + } + + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid notification ID", http.StatusBadRequest) + return + } + + query := ` + UPDATE admin_notifications + SET acknowledged_at = NOW() + WHERE id = $1 AND acknowledged_at IS NULL + ` + + cmdTag, err := db.DB.Exec(r.Context(), query, id) + if err != nil { + log.Printf("Failed to acknowledge notification %d: %v", id, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + if cmdTag.RowsAffected() == 0 { + http.Error(w, "Notification not found or already acknowledged", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + }) +} diff --git a/frontend/src/routes/manage/+page.svelte b/frontend/src/routes/manage/+page.svelte new file mode 100644 index 0000000..0326e40 --- /dev/null +++ b/frontend/src/routes/manage/+page.svelte @@ -0,0 +1,448 @@ + + +{#if pageState === 'loading'} + +
Pending bookings requiring confirmation or action
+| Name | +Status | +Date | +Actions | +
|---|---|---|---|
| Item {i + 1} | ++ + {i % 2 === 0 ? 'Active' : 'Pending'} + + | +2024-01-{String(i + 1).padStart(2, '0')} | +
+
+
+
+
+ |
+