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
This commit is contained in:
@@ -2,6 +2,7 @@ package bookings
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
|
"crussell/handlers/notifications"
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -1459,6 +1460,11 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := notifications.AcknowledgePendingBookingNotification(tx, r.Context(), bookingID); err != nil {
|
||||||
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Update service overrides individually
|
// Update service overrides individually
|
||||||
if len(req.ServiceOverrides) > 0 {
|
if len(req.ServiceOverrides) > 0 {
|
||||||
// First, verify all service IDs belong to this booking
|
// 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())
|
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
|
// Update booking status instead of deleting
|
||||||
query := `
|
query := `
|
||||||
UPDATE bookings
|
UPDATE bookings
|
||||||
@@ -1592,7 +1611,14 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create admin notification for cancelled booking
|
// 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 := `
|
notificationQuery := `
|
||||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
@@ -1603,6 +1629,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Commit(r.Context()); err != nil {
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
@@ -1628,7 +1655,27 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback(r.Context())
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
// Create admin notification BEFORE deleting the booking
|
// 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 {
|
||||||
|
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 := `
|
notificationQuery := `
|
||||||
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
INSERT INTO admin_notifications (reason, booking_id, user_id)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
@@ -1639,6 +1686,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Now delete the booking
|
// Now delete the booking
|
||||||
query := "DELETE FROM bookings WHERE id = $1 AND user_id = $2"
|
query := "DELETE FROM bookings WHERE id = $1 AND user_id = $2"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package bookings
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
|
"crussell/handlers/notifications"
|
||||||
"crussell/mw"
|
"crussell/mw"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -13,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// UserCancelBookingHandler allows an authenticated user to cancel a booking they own.
|
// 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) {
|
func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
bookingID := chi.URLParam(r, "id")
|
bookingID := chi.URLParam(r, "id")
|
||||||
|
|
||||||
@@ -23,7 +24,28 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
UPDATE bookings
|
||||||
SET status = 'client_cancelled', updated_at = $1
|
SET status = 'client_cancelled', updated_at = $1
|
||||||
WHERE id = $2 AND user_id = $3 AND status IN ('pending', 'confirmed', 'in_progress')
|
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
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +95,28 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
bookingID := chi.URLParam(r, "id")
|
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
|
UPDATE bookings
|
||||||
SET status = 'we_cancelled', updated_at = $1
|
SET status = 'we_cancelled', updated_at = $1
|
||||||
WHERE id = $2 AND status IN ('pending', 'confirmed', 'in_progress')
|
WHERE id = $2 AND status IN ('pending', 'confirmed', 'in_progress')
|
||||||
@@ -63,6 +132,32 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package notifications
|
package notifications
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Structs returned in JSON
|
// Structs returned in JSON
|
||||||
@@ -173,3 +175,19 @@ func AcknowledgeNotification(w http.ResponseWriter, r *http.Request) {
|
|||||||
"status": "ok",
|
"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
|
||||||
|
}
|
||||||
|
|||||||
@@ -351,6 +351,21 @@ CREATE TABLE admin_notifications (
|
|||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
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_payments_booking_id_status ON payments(booking_id, status);
|
||||||
CREATE INDEX idx_bookings_start_time_status ON bookings(start_time, status);
|
CREATE INDEX idx_bookings_start_time_status ON bookings(start_time, status);
|
||||||
CREATE INDEX idx_users_created_at ON users(created_at);
|
CREATE INDEX idx_users_created_at ON users(created_at);
|
||||||
|
|||||||
Reference in New Issue
Block a user