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:
2026-05-17 00:24:13 +01:00
parent 7fc58f58d9
commit b08df624a8
9 changed files with 462 additions and 62 deletions
+94
View File
@@ -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