From bbb273c19242d5cc435ce63e364b850a0e7a620b Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Tue, 17 Feb 2026 21:38:50 +0000 Subject: [PATCH] feat(notifications): add admin notification acknowledgement on booking state changes - Add AcknowledgePendingBookingNotification helper for acknowledging notifications - ConfirmBookingHandler: acknowledge pending notification when booking confirmed - Cancel handlers: acknowledge pending notification and only create cancelled_booking notification if booking was not in pending status - Add user_notification_preferences table with email, sms, push enabled flags - Update cancellation logic to check original status before creating notifications --- backend/handlers/bookings/bookings.go | 78 +++++++++++--- backend/handlers/bookings/manage.go | 101 +++++++++++++++++- .../handlers/notifications/notifications.go | 18 ++++ init-scripts/init-script.sql | 15 +++ 4 files changed, 194 insertions(+), 18 deletions(-) diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index f995f7b..0c64bc7 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -2,6 +2,7 @@ package bookings import ( "crussell/db" + "crussell/handlers/notifications" "crussell/mw" "database/sql" "encoding/json" @@ -1459,6 +1460,11 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) { return } + if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + // Update service overrides individually if len(req.ServiceOverrides) > 0 { // First, verify all service IDs belong to this booking @@ -1574,6 +1580,19 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) + // Get current status before updating + var originalStatus string + err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Booking not found or access denied", http.StatusNotFound) + return + } + log.Printf("Failed to get booking status %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + // Update booking status instead of deleting query := ` UPDATE bookings @@ -1592,18 +1611,26 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { return } - // Create admin notification for cancelled booking - notificationQuery := ` - INSERT INTO admin_notifications (reason, booking_id, user_id) - VALUES ($1, $2, $3) - ` - _, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID) - if err != nil { - log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err) + // Acknowledge pending notification if exists + if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } + // Only notify on cancellation if booking was confirmed (not pending) + if originalStatus == "confirmed" { + notificationQuery := ` + INSERT INTO admin_notifications (reason, booking_id, user_id) + VALUES ($1, $2, $3) + ` + _, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID) + if err != nil { + log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + if err := tx.Commit(r.Context()); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return @@ -1628,18 +1655,39 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) { } defer tx.Rollback(r.Context()) - // Create admin notification BEFORE deleting the booking - notificationQuery := ` - INSERT INTO admin_notifications (reason, booking_id, user_id) - VALUES ($1, $2, $3) - ` - _, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID) + // Get current status before deleting + var originalStatus string + err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus) if err != nil { - log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err) + if err == sql.ErrNoRows { + http.Error(w, "Booking not found or access denied", http.StatusNotFound) + return + } + log.Printf("Failed to get booking status %s: %v", bookingID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } + // Acknowledge pending notification if exists + if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress) + if originalStatus != "pending" { + notificationQuery := ` + INSERT INTO admin_notifications (reason, booking_id, user_id) + VALUES ($1, $2, $3) + ` + _, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID) + if err != nil { + log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + // Now delete the booking query := "DELETE FROM bookings WHERE id = $1 AND user_id = $2" result, err := tx.Exec(r.Context(), query, bookingID, userID) diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index fc4275e..6ab1d0b 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -2,6 +2,7 @@ package bookings import ( "crussell/db" + "crussell/handlers/notifications" "crussell/mw" "database/sql" "encoding/json" @@ -13,7 +14,7 @@ import ( ) // UserCancelBookingHandler allows an authenticated user to cancel a booking they own. -// The update is performed in a single statement with appropriate conditions. +// The update is performed in a transaction with notification handling. func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") @@ -23,7 +24,28 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) { return } - res, err := db.DB.Exec(r.Context(), ` + tx, err := db.DB.Begin(r.Context()) + if err != nil { + log.Printf("Failed to start transaction: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + // Get current status before updating + var originalStatus string + err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Booking not cancellable", http.StatusNotFound) + return + } + log.Printf("Failed to get booking status %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + res, err := tx.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') @@ -39,6 +61,32 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // Acknowledge pending notification if exists + if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress) + if originalStatus != "pending" { + notificationQuery := ` + INSERT INTO admin_notifications (reason, booking_id, user_id) + VALUES ($1, $2, $3) + ` + _, err = tx.Exec(r.Context(), notificationQuery, "cancelled_booking", bookingID, userID) + if err != nil { + log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit user cancel: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) } @@ -47,7 +95,28 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) { func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) { bookingID := chi.URLParam(r, "id") - res, err := db.DB.Exec(r.Context(), ` + tx, err := db.DB.Begin(r.Context()) + if err != nil { + log.Printf("Failed to start transaction: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + // Get current status before updating + var originalStatus string + err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&originalStatus) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "Booking not cancellable", http.StatusNotFound) + return + } + log.Printf("Failed to get booking status %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + res, err := tx.Exec(r.Context(), ` UPDATE bookings SET status = 'we_cancelled', updated_at = $1 WHERE id = $2 AND status IN ('pending', 'confirmed', 'in_progress') @@ -63,6 +132,32 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) { return } + // Acknowledge pending notification if exists + if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil { + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Only notify on cancellation if booking was not pending (e.g. confirmed, in_progress) + if originalStatus != "pending" { + notificationQuery := ` + INSERT INTO admin_notifications (reason, booking_id, user_id) + SELECT 'cancelled_booking', $1, user_id FROM bookings WHERE id = $1 + ` + _, err = tx.Exec(r.Context(), notificationQuery, bookingID) + if err != nil { + log.Printf("Failed to create admin notification for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + } + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit admin cancel: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) } diff --git a/backend/handlers/notifications/notifications.go b/backend/handlers/notifications/notifications.go index b740aff..6dce90c 100644 --- a/backend/handlers/notifications/notifications.go +++ b/backend/handlers/notifications/notifications.go @@ -1,6 +1,7 @@ package notifications import ( + "context" "crussell/db" "database/sql" "encoding/json" @@ -11,6 +12,7 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5/pgconn" ) // Structs returned in JSON @@ -173,3 +175,19 @@ func AcknowledgeNotification(w http.ResponseWriter, r *http.Request) { "status": "ok", }) } + +func AcknowledgePendingBookingNotification(tx interface{}, ctx context.Context, bookingID string) error { + query := ` + UPDATE admin_notifications + SET acknowledged_at = NOW() + WHERE booking_id = $1 AND reason = 'pending_booking' AND acknowledged_at IS NULL + ` + _, err := tx.(interface { + Exec(ctx context.Context, sql string, arguments ...interface{}) (pgconn.CommandTag, error) + }).Exec(ctx, query, bookingID) + if err != nil { + log.Printf("Failed to acknowledge pending booking notification for %s: %v", bookingID, err) + return err + } + return nil +} diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index 3ee59b8..e10d5d2 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -351,6 +351,21 @@ CREATE TABLE admin_notifications ( created_at TIMESTAMPTZ DEFAULT NOW() ); +-- User notification preferences for future user notification system +CREATE TABLE user_notification_preferences ( + id SERIAL PRIMARY KEY, + user_id CHAR(12) REFERENCES users(id) ON DELETE CASCADE, + notification_type VARCHAR(50) NOT NULL, + email_enabled BOOLEAN DEFAULT true, + sms_enabled BOOLEAN DEFAULT true, + push_enabled BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, notification_type) +); + +CREATE INDEX idx_user_notification_preferences_user_id ON user_notification_preferences(user_id); + CREATE INDEX idx_payments_booking_id_status ON payments(booking_id, status); CREATE INDEX idx_bookings_start_time_status ON bookings(start_time, status); CREATE INDEX idx_users_created_at ON users(created_at);