From b08df624a89020c2b5a43970584d5f734437bd8a Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sun, 17 May 2026 00:24:13 +0100 Subject: [PATCH] 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). --- README.md | 2 + backend/handlers/bookings/bookings_test.go | 6 +- backend/handlers/user/profile.go | 94 +++++++ backend/handlers/user/profile_test.go | 132 ++++++++- backend/main.go | 2 + frontend/src/routes/account/+page.svelte | 252 +++++++++++++++--- init-scripts/init-script.sql | 23 +- .../Crussell/Future Work - Gap Backlog.md | 4 +- obsidian/Crussell/Technical Manual.md | 9 + 9 files changed, 462 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index ed6d203..297b5a8 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ Nail salon booking platform — Go 1.25 backend + SvelteKit 5 frontend + Docker. - **Loyalty & discounts**: stamp-based loyalty, time-based and milestone campaigns - **Portfolio gallery**: S3/R2 storage with tag/category filtering - **CardDAV sync**: profile photos synced to SabreDAV contacts +- **Admin notifications**: priority-sorted queue with bell icon, `/notifications` page, acknowledge flow +- **User notification preferences**: per-channel toggles (email, SMS, browser) in account settings ## Project Structure diff --git a/backend/handlers/bookings/bookings_test.go b/backend/handlers/bookings/bookings_test.go index b0af989..e05e4b7 100644 --- a/backend/handlers/bookings/bookings_test.go +++ b/backend/handlers/bookings/bookings_test.go @@ -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(), diff --git a/backend/handlers/user/profile.go b/backend/handlers/user/profile.go index dbf13e2..8afce2a 100644 --- a/backend/handlers/user/profile.go +++ b/backend/handlers/user/profile.go @@ -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 diff --git a/backend/handlers/user/profile_test.go b/backend/handlers/user/profile_test.go index 0d98bf1..91babaa 100644 --- a/backend/handlers/user/profile_test.go +++ b/backend/handlers/user/profile_test.go @@ -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") + } +} diff --git a/backend/main.go b/backend/main.go index 3bd6767..1b2e4ee 100644 --- a/backend/main.go +++ b/backend/main.go @@ -206,6 +206,8 @@ func main() { r.Put("/user/profile", user.UpdateProfileHandler) r.Post("/user/profile-picture", user.UploadProfilePictureHandler) r.Put("/user/change-password", user.ChangePasswordHandler) + r.Get("/user/notification-preferences", user.GetNotificationPreferencesHandler) + r.Put("/user/notification-preferences", user.UpdateNotificationPreferencesHandler) r.Delete("/user/account", user.DeleteAccountHandler) r.Get("/user/loyalty", user.GetLoyaltyHandler) diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 552c425..9c1d187 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -84,7 +84,40 @@ let stamps = $state(0); let pendingRedemption = $state(false); let uploadingPic = $state(false); - + + let notifPrefs = $state({ emailEnabled: true, smsEnabled: true, browserPushEnabled: true }); + + async function fetchNotifPrefs() { + try { + const res = await fetch('/api/user/notification-preferences', { + headers: { Authorization: `Bearer ${authStore.currentToken}` } + }); + if (res.ok) { + const data = await res.json(); + notifPrefs.emailEnabled = data.emailEnabled ?? true; + notifPrefs.smsEnabled = data.smsEnabled ?? true; + notifPrefs.browserPushEnabled = data.browserPushEnabled ?? true; + } + } catch { + /* silently fail */ + } + } + + async function saveNotifPrefs() { + try { + await fetch('/api/user/notification-preferences', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + }, + body: JSON.stringify(notifPrefs) + }); + } catch { + /* silently fail */ + } + } + // Image cropper state let cropDialogOpen = $state(false); let cropImageUrl = $state(''); @@ -92,7 +125,7 @@ let crop = $state({ x: 0, y: 0 }); let zoom = $state(1); let previewUrl = $state(''); - + function handleFileSelect(e: Event) { const input = e.target as HTMLInputElement; const file = input.files?.[0]; @@ -101,41 +134,43 @@ cropDialogOpen = true; } } - + async function handleCropSave() { if (!cropArea || !cropImageUrl) return; - + const img = new Image(); img.src = cropImageUrl; - await new Promise(resolve => { img.onload = resolve; }); - + await new Promise((resolve) => { + img.onload = resolve; + }); + const canvas = document.createElement('canvas'); canvas.width = 350; canvas.height = 350; const ctx = canvas.getContext('2d'); if (!ctx) return; - - ctx.drawImage( - img, - cropArea.x, cropArea.y, cropArea.width, cropArea.height, - 0, 0, 350, 350 + + ctx.drawImage(img, cropArea.x, cropArea.y, cropArea.width, cropArea.height, 0, 0, 350, 350); + + canvas.toBlob( + (blob) => { + if (!blob) return; + + const url = URL.createObjectURL(blob); + previewUrl = url; + + handleProfilePicUpload(blob).then(() => { + URL.revokeObjectURL(cropImageUrl); + cropImageUrl = ''; + cropArea = null; + cropDialogOpen = false; + }); + }, + 'image/jpeg', + 0.9 ); - - canvas.toBlob((blob) => { - if (!blob) return; - - const url = URL.createObjectURL(blob); - previewUrl = url; - - handleProfilePicUpload(blob).then(() => { - URL.revokeObjectURL(cropImageUrl); - cropImageUrl = ''; - cropArea = null; - cropDialogOpen = false; - }); - }, 'image/jpeg', 0.9); } - + function handleCropCancel() { if (cropImageUrl) { URL.revokeObjectURL(cropImageUrl); @@ -425,6 +460,7 @@ fetchUserData(); fetchUpcomingBookings(); fetchPastBookings(); + fetchNotifPrefs(); } }); @@ -439,8 +475,12 @@ // Password strength using zxcvbn let newPasswordStrength = $derived(passwordData.new ? zxcvbn(passwordData.new) : null); - let isPasswordStrongEnough = $derived(!passwordData.new || newPasswordStrength === null || newPasswordStrength.score >= 2); - let passwordsMatch = $derived(passwordData.confirm === '' || passwordData.new === passwordData.confirm); + let isPasswordStrongEnough = $derived( + !passwordData.new || newPasswordStrength === null || newPasswordStrength.score >= 2 + ); + let passwordsMatch = $derived( + passwordData.confirm === '' || passwordData.new === passwordData.confirm + ); async function changePassword() { if (passwordData.new !== passwordData.confirm) { @@ -702,26 +742,58 @@ {#if userData} - {@const initials = userData.firstName && userData.lastName ? userData.firstName.split(' ').map(n => n[0]).join('') + userData.lastName.split(' ').map(n => n[0]).join('') : ''} + {@const initials = + userData.firstName && userData.lastName + ? userData.firstName + .split(' ') + .map((n) => n[0]) + .join('') + + userData.lastName + .split(' ') + .map((n) => n[0]) + .join('') + : ''} {@const hasImage = !!userData.profilePicUrl || !!previewUrl} {@const displayUrl = previewUrl || userData.profilePicUrl || ''}
{#if hasImage || initials} {#if hasImage} - Profile + Profile {:else} -
+
{initials}
{/if} {:else} -
- - +
+ +
{/if} - {savingPhone ? 'Saving...' : 'Save'} -
{:else} -
+
{userData.phone || '—'}
+ + + +
+

Notifications

+

+ Choose how you receive booking reminders and updates +

+
+
+
+
Email
+
Booking confirmations and reminders
+
+ +
+ +
+
+
SMS
+
Text message reminders
+
+ +
+ +
+
+
Browser
+
In-browser notifications
+
+ +
+
+
+ + +

Session

@@ -1255,7 +1418,8 @@
-

+

{newPasswordStrength.feedback.warning ? newPasswordStrength.feedback.warning : `Strength: ${['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'][newPasswordStrength.score]}`} diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index 390001a..2fd6299 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -538,13 +538,10 @@ CREATE TABLE admin_notifications ( 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) + email_enabled BOOLEAN DEFAULT false, + sms_enabled BOOLEAN DEFAULT false, + browser_push_enabled BOOLEAN DEFAULT false, + updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX idx_user_notification_preferences_user_id ON user_notification_preferences(user_id); @@ -776,14 +773,12 @@ BEGIN 'notification_preferences', ( SELECT COALESCE(json_agg( json_build_object( - 'notification_type', unp.notification_type, - 'email_enabled', unp.email_enabled, - 'sms_enabled', unp.sms_enabled, - 'push_enabled', unp.push_enabled, - 'created_at', unp.created_at, - 'updated_at', unp.updated_at + 'email_enabled', unp.email_enabled, + 'sms_enabled', unp.sms_enabled, + 'browser_push_enabled', unp.browser_push_enabled, + 'updated_at', unp.updated_at ) - ORDER BY unp.notification_type), '[]'::json) + ), '[]'::json) FROM user_notification_preferences unp WHERE unp.user_id = target_user_id ), diff --git a/obsidian/Crussell/Future Work - Gap Backlog.md b/obsidian/Crussell/Future Work - Gap Backlog.md index 678a806..1f565d9 100644 --- a/obsidian/Crussell/Future Work - Gap Backlog.md +++ b/obsidian/Crussell/Future Work - Gap Backlog.md @@ -36,7 +36,7 @@ No external dependencies. No paid services. No API keys needed. | # | Gap | Effort | Area | Notes | |---|-----|--------|------|-------| | 14 | ~~`delete_guest_user()` SQL function missing~~ ✅ | S (1h) | DB | Created next to `anonymize_user()` in init-script.sql. Called by `DeleteAccountHandler` for guest users. | -| 15 | **User notification preferences UI** | S (2-3h) | Frontend | DB table `user_notification_preferences` exists with email/sms/push flags. No settings page to toggle them. | +| ~~15~~ | ~~**User notification preferences UI**~~ ✅ | ~~S (2-3h)~~ | ~~Frontend~~ | ~~DB table `user_notification_preferences` exists with email/sms/push flags. No settings page to toggle them.~~ GET/PUT endpoints wired. Toggle section in /account Admin tab. Email, SMS, Browser push channels. Auto-save on toggle. | | 16 | **One-off custom services** | M (1-2d) | Full-stack | Admin can't create single-use services outside the catalog. Every custom job (bridal party, special request) must be added to permanent service list. | | 17 | **One-off exceptional hours** | M (1d) | Full-stack | Single-day overrides (dentist appointment, afternoon off) require creating a full exceptional group. Should support one-off date blocks without group overhead. | | 18 | ~~**HSTS header**~~ ✅ | XS (15min) | Backend | Added as a TODO-comment in the security headers middleware. Will be uncommented when HTTPS is enabled in production. | @@ -143,7 +143,7 @@ Require paid accounts, API approval, or external service credentials. **Do not a │ │ │ #3 Approval decline ✅──→ #13 Booking reschedule │ │ │ -│ ~~#5 Admin notification panel~~ ✅ ──→ #15 Preferences UI │ +│ ~~#5 Admin notification panel~~ ✅ ──→ ~~#15 Preferences UI~~ ✅ │ │ ──→ #48 Waitlist (removed) │ │ │ │ #2 Walk-in guest fix ──→ #9 Reservation transition │ diff --git a/obsidian/Crussell/Technical Manual.md b/obsidian/Crussell/Technical Manual.md index af73075..75faf10 100644 --- a/obsidian/Crussell/Technical Manual.md +++ b/obsidian/Crussell/Technical Manual.md @@ -189,6 +189,8 @@ src/lib/components/ | PUT | `/api/user/profile` | Update profile | | POST | `/api/user/profile-picture` | Upload profile picture (cropper) | | PUT | `/api/user/change-password` | Change password | +| GET | `/api/user/notification-preferences` | Get notification preferences | +| PUT | `/api/user/notification-preferences` | Update notification preferences | | DELETE | `/api/user/account` | Delete account (GDPR anonymization) | | GET | `/api/user/loyalty` | Get loyalty stamp count | | GET | `/api/bookings` | List user's bookings | @@ -484,6 +486,13 @@ src/lib/components/ - Edit requests (`POST /api/bookings/{id}/edit-request`) → always `edit_requested`, plus `pending_booking` if booking status is pending - Admin bookings (`POST /api/admin/bookings`) → no notifications (admin already knows) +**User notification preferences:** +Users manage their preferred notification channels via `/account` → Admin tab → Notifications section. The `user_notification_preferences` table stores per-user flags for email, SMS, and browser push. These flags are not yet used by any delivery system — they will be consumed when the email/SMS notification system (E5) is built. + +**Endpoints:** +- `GET /api/user/notification-preferences` — Returns `{emailEnabled, smsEnabled, browserPushEnabled}`. Defaults to all `true` if no row exists. +- `PUT /api/user/notification-preferences` — Accepts partial updates (only provided fields change, unset fields retain current value). Upserts on first call. + ### Loyalty & Discount System **Loyalty Stamps:**