feat: user notification preferences UI and API endpoints
GET/PUT /api/user/notification-preferences with partial update support. Toggle section in /account Admin tab (Email, SMS, Browser push). 3 new tests: defaults, full update, partial update. Fix pre-existing timezone bug in exceptional hours tests (Truncate vs time.Date). Update README, Technical Manual, and gap backlog (#15 struck out).
This commit is contained in:
@@ -4154,7 +4154,8 @@ func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) {
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7
|
||||
}
|
||||
weekStart := targetDate.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
|
||||
mondayDate := targetDate.AddDate(0, 0, -daysToMonday+1)
|
||||
weekStart := time.Date(mondayDate.Year(), mondayDate.Month(), mondayDate.Day(), 0, 0, 0, 0, mondayDate.Location())
|
||||
|
||||
// Apply group to this week
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
@@ -4240,7 +4241,8 @@ func TestBookings_Edit_OpenDay_UserAllowed(t *testing.T) {
|
||||
if daysToMonday == 0 {
|
||||
daysToMonday = 7
|
||||
}
|
||||
weekStart := targetDate.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
|
||||
mondayDate := targetDate.AddDate(0, 0, -daysToMonday+1)
|
||||
weekStart := time.Date(mondayDate.Year(), mondayDate.Month(), mondayDate.Day(), 0, 0, 0, 0, mondayDate.Location())
|
||||
|
||||
// Apply group to this week
|
||||
_, err = db.DB.Exec(context.Background(),
|
||||
|
||||
@@ -869,6 +869,100 @@ type ContactInfo struct {
|
||||
ProfilePicURL *string `json:"profilePicUrl,omitempty"`
|
||||
}
|
||||
|
||||
type NotificationPreferencesResponse struct {
|
||||
EmailEnabled bool `json:"emailEnabled"`
|
||||
SMSEnabled bool `json:"smsEnabled"`
|
||||
BrowserPushEnabled bool `json:"browserPushEnabled"`
|
||||
}
|
||||
|
||||
type UpdateNotificationPreferencesRequest struct {
|
||||
EmailEnabled *bool `json:"emailEnabled"`
|
||||
SMSEnabled *bool `json:"smsEnabled"`
|
||||
BrowserPushEnabled *bool `json:"browserPushEnabled"`
|
||||
}
|
||||
|
||||
// GET /api/user/notification-preferences
|
||||
func GetNotificationPreferencesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := mw.GetUserID(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var prefs NotificationPreferencesResponse
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT email_enabled, sms_enabled, browser_push_enabled
|
||||
FROM user_notification_preferences
|
||||
WHERE user_id = $1
|
||||
`, userID).Scan(&prefs.EmailEnabled, &prefs.SMSEnabled, &prefs.BrowserPushEnabled)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
prefs = NotificationPreferencesResponse{
|
||||
EmailEnabled: true,
|
||||
SMSEnabled: true,
|
||||
BrowserPushEnabled: true,
|
||||
}
|
||||
} else {
|
||||
log.Printf("Failed to fetch notification preferences for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(prefs)
|
||||
}
|
||||
|
||||
// PUT /api/user/notification-preferences
|
||||
func UpdateNotificationPreferencesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := mw.GetUserID(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateNotificationPreferencesRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var exists bool
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT EXISTS(SELECT 1 FROM user_notification_preferences WHERE user_id = $1)
|
||||
`, userID).Scan(&exists)
|
||||
if err != nil {
|
||||
log.Printf("Failed to check notification preferences for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if exists {
|
||||
_, err = db.DB.Exec(r.Context(), `
|
||||
UPDATE user_notification_preferences SET
|
||||
email_enabled = COALESCE($2, email_enabled),
|
||||
sms_enabled = COALESCE($3, sms_enabled),
|
||||
browser_push_enabled = COALESCE($4, browser_push_enabled),
|
||||
updated_at = NOW()
|
||||
WHERE user_id = $1
|
||||
`, userID, req.EmailEnabled, req.SMSEnabled, req.BrowserPushEnabled)
|
||||
} else {
|
||||
_, err = db.DB.Exec(r.Context(), `
|
||||
INSERT INTO user_notification_preferences (user_id, email_enabled, sms_enabled, browser_push_enabled, updated_at)
|
||||
VALUES ($1, COALESCE($2, true), COALESCE($3, true), COALESCE($4, true), NOW())
|
||||
`, userID, req.EmailEnabled, req.SMSEnabled, req.BrowserPushEnabled)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to update notification preferences for user %s: %v", userID, err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// GET /api/contact
|
||||
func GetContactInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var contact ContactInfo
|
||||
|
||||
@@ -632,16 +632,13 @@ func TestContactInfo_ReturnsAdmin(t *testing.T) {
|
||||
func TestContactInfo_NoAdmin(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
// Ensure no admin users exist - truncate tables
|
||||
testdb.TruncateTables(t, db.DB)
|
||||
|
||||
// Create only a regular user (not admin)
|
||||
_, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
// Call handler
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/contact", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
GetContactInfoHandler(rr, req)
|
||||
@@ -651,3 +648,132 @@ func TestContactInfo_NoAdmin(t *testing.T) {
|
||||
t.Logf("response body: %s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationPreferences_Get_Defaults(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/user/notification-preferences", nil)
|
||||
req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
GetNotificationPreferencesHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d. body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp NotificationPreferencesResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if !resp.EmailEnabled {
|
||||
t.Error("expected emailEnabled to default to true")
|
||||
}
|
||||
if !resp.SMSEnabled {
|
||||
t.Error("expected smsEnabled to default to true")
|
||||
}
|
||||
if !resp.BrowserPushEnabled {
|
||||
t.Error("expected browserPushEnabled to default to true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationPreferences_Update(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
falseVal := false
|
||||
trueVal := true
|
||||
reqBody := UpdateNotificationPreferencesRequest{
|
||||
EmailEnabled: &falseVal,
|
||||
SMSEnabled: &trueVal,
|
||||
BrowserPushEnabled: &falseVal,
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/user/notification-preferences", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
UpdateNotificationPreferencesHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d. body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/user/notification-preferences", nil)
|
||||
getReq = getReq.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID))
|
||||
getRR := httptest.NewRecorder()
|
||||
GetNotificationPreferencesHandler(getRR, getReq)
|
||||
|
||||
var resp NotificationPreferencesResponse
|
||||
if err := json.Unmarshal(getRR.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if resp.EmailEnabled {
|
||||
t.Error("expected emailEnabled to be false after update")
|
||||
}
|
||||
if !resp.SMSEnabled {
|
||||
t.Error("expected smsEnabled to be true after update")
|
||||
}
|
||||
if resp.BrowserPushEnabled {
|
||||
t.Error("expected browserPushEnabled to be false after update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationPreferences_Update_Partial(t *testing.T) {
|
||||
resetTestData(t)
|
||||
|
||||
userID, err := fixtures.CreateTestUser(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
|
||||
falseVal := false
|
||||
reqBody := UpdateNotificationPreferencesRequest{
|
||||
EmailEnabled: &falseVal,
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/user/notification-preferences", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
UpdateNotificationPreferencesHandler(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d. body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
getReq := httptest.NewRequest(http.MethodGet, "/api/user/notification-preferences", nil)
|
||||
getReq = getReq.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID))
|
||||
getRR := httptest.NewRecorder()
|
||||
GetNotificationPreferencesHandler(getRR, getReq)
|
||||
|
||||
var resp NotificationPreferencesResponse
|
||||
if err := json.Unmarshal(getRR.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if resp.EmailEnabled {
|
||||
t.Error("expected emailEnabled to be false")
|
||||
}
|
||||
if !resp.SMSEnabled {
|
||||
t.Error("expected smsEnabled to retain default true")
|
||||
}
|
||||
if !resp.BrowserPushEnabled {
|
||||
t.Error("expected browserPushEnabled to retain default true")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user