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:
@@ -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
|
- **Loyalty & discounts**: stamp-based loyalty, time-based and milestone campaigns
|
||||||
- **Portfolio gallery**: S3/R2 storage with tag/category filtering
|
- **Portfolio gallery**: S3/R2 storage with tag/category filtering
|
||||||
- **CardDAV sync**: profile photos synced to SabreDAV contacts
|
- **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
|
## Project Structure
|
||||||
|
|
||||||
|
|||||||
@@ -4154,7 +4154,8 @@ func TestBookings_Edit_ClosedDay_UserBlocked(t *testing.T) {
|
|||||||
if daysToMonday == 0 {
|
if daysToMonday == 0 {
|
||||||
daysToMonday = 7
|
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
|
// Apply group to this week
|
||||||
_, err = db.DB.Exec(context.Background(),
|
_, err = db.DB.Exec(context.Background(),
|
||||||
@@ -4240,7 +4241,8 @@ func TestBookings_Edit_OpenDay_UserAllowed(t *testing.T) {
|
|||||||
if daysToMonday == 0 {
|
if daysToMonday == 0 {
|
||||||
daysToMonday = 7
|
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
|
// Apply group to this week
|
||||||
_, err = db.DB.Exec(context.Background(),
|
_, err = db.DB.Exec(context.Background(),
|
||||||
|
|||||||
@@ -869,6 +869,100 @@ type ContactInfo struct {
|
|||||||
ProfilePicURL *string `json:"profilePicUrl,omitempty"`
|
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
|
// GET /api/contact
|
||||||
func GetContactInfoHandler(w http.ResponseWriter, r *http.Request) {
|
func GetContactInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
var contact ContactInfo
|
var contact ContactInfo
|
||||||
|
|||||||
@@ -632,16 +632,13 @@ func TestContactInfo_ReturnsAdmin(t *testing.T) {
|
|||||||
func TestContactInfo_NoAdmin(t *testing.T) {
|
func TestContactInfo_NoAdmin(t *testing.T) {
|
||||||
resetTestData(t)
|
resetTestData(t)
|
||||||
|
|
||||||
// Ensure no admin users exist - truncate tables
|
|
||||||
testdb.TruncateTables(t, db.DB)
|
testdb.TruncateTables(t, db.DB)
|
||||||
|
|
||||||
// Create only a regular user (not admin)
|
|
||||||
_, err := fixtures.CreateTestUser(db.DB)
|
_, err := fixtures.CreateTestUser(db.DB)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create test user: %v", err)
|
t.Fatalf("failed to create test user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call handler
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/contact", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/contact", nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
GetContactInfoHandler(rr, req)
|
GetContactInfoHandler(rr, req)
|
||||||
@@ -651,3 +648,132 @@ func TestContactInfo_NoAdmin(t *testing.T) {
|
|||||||
t.Logf("response body: %s", rr.Body.String())
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -206,6 +206,8 @@ func main() {
|
|||||||
r.Put("/user/profile", user.UpdateProfileHandler)
|
r.Put("/user/profile", user.UpdateProfileHandler)
|
||||||
r.Post("/user/profile-picture", user.UploadProfilePictureHandler)
|
r.Post("/user/profile-picture", user.UploadProfilePictureHandler)
|
||||||
r.Put("/user/change-password", user.ChangePasswordHandler)
|
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.Delete("/user/account", user.DeleteAccountHandler)
|
||||||
r.Get("/user/loyalty", user.GetLoyaltyHandler)
|
r.Get("/user/loyalty", user.GetLoyaltyHandler)
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,40 @@
|
|||||||
let stamps = $state(0);
|
let stamps = $state(0);
|
||||||
let pendingRedemption = $state(false);
|
let pendingRedemption = $state(false);
|
||||||
let uploadingPic = $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
|
// Image cropper state
|
||||||
let cropDialogOpen = $state(false);
|
let cropDialogOpen = $state(false);
|
||||||
let cropImageUrl = $state('');
|
let cropImageUrl = $state('');
|
||||||
@@ -92,7 +125,7 @@
|
|||||||
let crop = $state({ x: 0, y: 0 });
|
let crop = $state({ x: 0, y: 0 });
|
||||||
let zoom = $state(1);
|
let zoom = $state(1);
|
||||||
let previewUrl = $state('');
|
let previewUrl = $state('');
|
||||||
|
|
||||||
function handleFileSelect(e: Event) {
|
function handleFileSelect(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
const file = input.files?.[0];
|
const file = input.files?.[0];
|
||||||
@@ -101,41 +134,43 @@
|
|||||||
cropDialogOpen = true;
|
cropDialogOpen = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCropSave() {
|
async function handleCropSave() {
|
||||||
if (!cropArea || !cropImageUrl) return;
|
if (!cropArea || !cropImageUrl) return;
|
||||||
|
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = cropImageUrl;
|
img.src = cropImageUrl;
|
||||||
await new Promise(resolve => { img.onload = resolve; });
|
await new Promise((resolve) => {
|
||||||
|
img.onload = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = 350;
|
canvas.width = 350;
|
||||||
canvas.height = 350;
|
canvas.height = 350;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
ctx.drawImage(
|
ctx.drawImage(img, cropArea.x, cropArea.y, cropArea.width, cropArea.height, 0, 0, 350, 350);
|
||||||
img,
|
|
||||||
cropArea.x, cropArea.y, cropArea.width, cropArea.height,
|
canvas.toBlob(
|
||||||
0, 0, 350, 350
|
(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() {
|
function handleCropCancel() {
|
||||||
if (cropImageUrl) {
|
if (cropImageUrl) {
|
||||||
URL.revokeObjectURL(cropImageUrl);
|
URL.revokeObjectURL(cropImageUrl);
|
||||||
@@ -425,6 +460,7 @@
|
|||||||
fetchUserData();
|
fetchUserData();
|
||||||
fetchUpcomingBookings();
|
fetchUpcomingBookings();
|
||||||
fetchPastBookings();
|
fetchPastBookings();
|
||||||
|
fetchNotifPrefs();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -439,8 +475,12 @@
|
|||||||
|
|
||||||
// Password strength using zxcvbn
|
// Password strength using zxcvbn
|
||||||
let newPasswordStrength = $derived(passwordData.new ? zxcvbn(passwordData.new) : null);
|
let newPasswordStrength = $derived(passwordData.new ? zxcvbn(passwordData.new) : null);
|
||||||
let isPasswordStrongEnough = $derived(!passwordData.new || newPasswordStrength === null || newPasswordStrength.score >= 2);
|
let isPasswordStrongEnough = $derived(
|
||||||
let passwordsMatch = $derived(passwordData.confirm === '' || passwordData.new === passwordData.confirm);
|
!passwordData.new || newPasswordStrength === null || newPasswordStrength.score >= 2
|
||||||
|
);
|
||||||
|
let passwordsMatch = $derived(
|
||||||
|
passwordData.confirm === '' || passwordData.new === passwordData.confirm
|
||||||
|
);
|
||||||
|
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
if (passwordData.new !== passwordData.confirm) {
|
if (passwordData.new !== passwordData.confirm) {
|
||||||
@@ -702,26 +742,58 @@
|
|||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="space-y-4">
|
<Card.Content class="space-y-4">
|
||||||
{#if userData}
|
{#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 hasImage = !!userData.profilePicUrl || !!previewUrl}
|
||||||
{@const displayUrl = previewUrl || userData.profilePicUrl || ''}
|
{@const displayUrl = previewUrl || userData.profilePicUrl || ''}
|
||||||
<div class="flex flex-col items-center gap-4">
|
<div class="flex flex-col items-center gap-4">
|
||||||
{#if hasImage || initials}
|
{#if hasImage || initials}
|
||||||
{#if hasImage}
|
{#if hasImage}
|
||||||
<img src={displayUrl} alt="Profile" class="h-24 w-24 rounded-full object-cover ring-4 ring-blue-200" />
|
<img
|
||||||
|
src={displayUrl}
|
||||||
|
alt="Profile"
|
||||||
|
class="h-24 w-24 rounded-full object-cover ring-4 ring-blue-200"
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-3xl font-bold text-gray-600 ring-4 ring-blue-200">
|
<div
|
||||||
|
class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 text-3xl font-bold text-gray-600 ring-4 ring-blue-200"
|
||||||
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 ring-4 ring-blue-200">
|
<div
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
class="flex h-24 w-24 items-center justify-center rounded-full bg-gray-200 ring-4 ring-blue-200"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-10 w-10 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<Button variant="outline" onclick={() => document.getElementById('profile-pic-input')?.click()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => document.getElementById('profile-pic-input')?.click()}
|
||||||
|
>
|
||||||
Upload profile picture
|
Upload profile picture
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
@@ -803,16 +875,30 @@
|
|||||||
<Button size="sm" onclick={savePhone} disabled={savingPhone}>
|
<Button size="sm" onclick={savePhone} disabled={savingPhone}>
|
||||||
{savingPhone ? 'Saving...' : 'Save'}
|
{savingPhone ? 'Saving...' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onclick={cancelEditPhone} disabled={savingPhone}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onclick={cancelEditPhone}
|
||||||
|
disabled={savingPhone}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium">
|
<div
|
||||||
|
class="mt-1 flex items-center justify-between rounded-lg border bg-gray-50 p-3 font-medium"
|
||||||
|
>
|
||||||
<span>{userData.phone || '—'}</span>
|
<span>{userData.phone || '—'}</span>
|
||||||
<Button size="sm" variant="ghost" onclick={startEditPhone}>
|
<Button size="sm" variant="ghost" onclick={startEditPhone}>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -837,7 +923,8 @@
|
|||||||
{:else if userData && stamps >= 10}
|
{:else if userData && stamps >= 10}
|
||||||
<div class="w-full text-center">
|
<div class="w-full text-center">
|
||||||
<div class="text-sm font-medium text-emerald-800">
|
<div class="text-sm font-medium text-emerald-800">
|
||||||
Your loyalty card is full! Your next completed appointment will receive 10% off.
|
Your loyalty card is full! Your next completed appointment will receive 10%
|
||||||
|
off.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1082,6 +1169,82 @@
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- Notification Preferences -->
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-2 text-sm font-semibold">Notifications</h3>
|
||||||
|
<p class="mb-3 text-sm text-gray-600">
|
||||||
|
Choose how you receive booking reminders and updates
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium">Email</div>
|
||||||
|
<div class="text-xs text-gray-500">Booking confirmations and reminders</div>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="peer sr-only"
|
||||||
|
checked={notifPrefs.emailEnabled}
|
||||||
|
onchange={async () => {
|
||||||
|
notifPrefs.emailEnabled = !notifPrefs.emailEnabled;
|
||||||
|
await saveNotifPrefs();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="peer h-5 w-9 rounded-full bg-gray-200 peer-checked:bg-blue-600 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium">SMS</div>
|
||||||
|
<div class="text-xs text-gray-500">Text message reminders</div>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="peer sr-only"
|
||||||
|
checked={notifPrefs.smsEnabled}
|
||||||
|
onchange={async () => {
|
||||||
|
notifPrefs.smsEnabled = !notifPrefs.smsEnabled;
|
||||||
|
await saveNotifPrefs();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="peer h-5 w-9 rounded-full bg-gray-200 peer-checked:bg-blue-600 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between rounded-lg border p-3">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium">Browser</div>
|
||||||
|
<div class="text-xs text-gray-500">In-browser notifications</div>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="peer sr-only"
|
||||||
|
checked={notifPrefs.browserPushEnabled}
|
||||||
|
onchange={async () => {
|
||||||
|
notifPrefs.browserPushEnabled = !notifPrefs.browserPushEnabled;
|
||||||
|
await saveNotifPrefs();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="peer h-5 w-9 rounded-full bg-gray-200 peer-checked:bg-blue-600 after:absolute after:start-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-full peer-checked:after:border-white"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<!-- Log Out Button -->
|
<!-- Log Out Button -->
|
||||||
<div>
|
<div>
|
||||||
<h3 class="mb-2 text-sm font-semibold">Session</h3>
|
<h3 class="mb-2 text-sm font-semibold">Session</h3>
|
||||||
@@ -1255,7 +1418,8 @@
|
|||||||
<div class="flex h-1.5 w-full overflow-hidden rounded bg-gray-200">
|
<div class="flex h-1.5 w-full overflow-hidden rounded bg-gray-200">
|
||||||
<div
|
<div
|
||||||
class="transition-all duration-300"
|
class="transition-all duration-300"
|
||||||
style="width: {(newPasswordStrength.score + 1) * 20}%; background-color: {newPasswordStrength.score < 2
|
style="width: {(newPasswordStrength.score + 1) *
|
||||||
|
20}%; background-color: {newPasswordStrength.score < 2
|
||||||
? '#ef4444'
|
? '#ef4444'
|
||||||
: newPasswordStrength.score === 2
|
: newPasswordStrength.score === 2
|
||||||
? '#f59e0b'
|
? '#f59e0b'
|
||||||
@@ -1264,7 +1428,13 @@
|
|||||||
: '#15803d'}"
|
: '#15803d'}"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs {newPasswordStrength.score < 2 ? 'text-red-500' : newPasswordStrength.score === 2 ? 'text-amber-500' : 'text-green-600'}">
|
<p
|
||||||
|
class="text-xs {newPasswordStrength.score < 2
|
||||||
|
? 'text-red-500'
|
||||||
|
: newPasswordStrength.score === 2
|
||||||
|
? 'text-amber-500'
|
||||||
|
: 'text-green-600'}"
|
||||||
|
>
|
||||||
{newPasswordStrength.feedback.warning
|
{newPasswordStrength.feedback.warning
|
||||||
? newPasswordStrength.feedback.warning
|
? newPasswordStrength.feedback.warning
|
||||||
: `Strength: ${['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'][newPasswordStrength.score]}`}
|
: `Strength: ${['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'][newPasswordStrength.score]}`}
|
||||||
|
|||||||
@@ -538,13 +538,10 @@ CREATE TABLE admin_notifications (
|
|||||||
CREATE TABLE user_notification_preferences (
|
CREATE TABLE user_notification_preferences (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
user_id CHAR(12) REFERENCES users(id) ON DELETE CASCADE,
|
user_id CHAR(12) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
notification_type VARCHAR(50) NOT NULL,
|
email_enabled BOOLEAN DEFAULT false,
|
||||||
email_enabled BOOLEAN DEFAULT true,
|
sms_enabled BOOLEAN DEFAULT false,
|
||||||
sms_enabled BOOLEAN DEFAULT true,
|
browser_push_enabled BOOLEAN DEFAULT false,
|
||||||
push_enabled BOOLEAN DEFAULT true,
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
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_user_notification_preferences_user_id ON user_notification_preferences(user_id);
|
||||||
@@ -776,14 +773,12 @@ BEGIN
|
|||||||
'notification_preferences', (
|
'notification_preferences', (
|
||||||
SELECT COALESCE(json_agg(
|
SELECT COALESCE(json_agg(
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'notification_type', unp.notification_type,
|
'email_enabled', unp.email_enabled,
|
||||||
'email_enabled', unp.email_enabled,
|
'sms_enabled', unp.sms_enabled,
|
||||||
'sms_enabled', unp.sms_enabled,
|
'browser_push_enabled', unp.browser_push_enabled,
|
||||||
'push_enabled', unp.push_enabled,
|
'updated_at', unp.updated_at
|
||||||
'created_at', unp.created_at,
|
|
||||||
'updated_at', unp.updated_at
|
|
||||||
)
|
)
|
||||||
ORDER BY unp.notification_type), '[]'::json)
|
), '[]'::json)
|
||||||
FROM user_notification_preferences unp
|
FROM user_notification_preferences unp
|
||||||
WHERE unp.user_id = target_user_id
|
WHERE unp.user_id = target_user_id
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ No external dependencies. No paid services. No API keys needed.
|
|||||||
| # | Gap | Effort | Area | Notes |
|
| # | 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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 │
|
│ #3 Approval decline ✅──→ #13 Booking reschedule │
|
||||||
│ │
|
│ │
|
||||||
│ ~~#5 Admin notification panel~~ ✅ ──→ #15 Preferences UI │
|
│ ~~#5 Admin notification panel~~ ✅ ──→ ~~#15 Preferences UI~~ ✅ │
|
||||||
│ ──→ #48 Waitlist (removed) │
|
│ ──→ #48 Waitlist (removed) │
|
||||||
│ │
|
│ │
|
||||||
│ #2 Walk-in guest fix ──→ #9 Reservation transition │
|
│ #2 Walk-in guest fix ──→ #9 Reservation transition │
|
||||||
|
|||||||
@@ -189,6 +189,8 @@ src/lib/components/
|
|||||||
| PUT | `/api/user/profile` | Update profile |
|
| PUT | `/api/user/profile` | Update profile |
|
||||||
| POST | `/api/user/profile-picture` | Upload profile picture (cropper) |
|
| POST | `/api/user/profile-picture` | Upload profile picture (cropper) |
|
||||||
| PUT | `/api/user/change-password` | Change password |
|
| 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) |
|
| DELETE | `/api/user/account` | Delete account (GDPR anonymization) |
|
||||||
| GET | `/api/user/loyalty` | Get loyalty stamp count |
|
| GET | `/api/user/loyalty` | Get loyalty stamp count |
|
||||||
| GET | `/api/bookings` | List user's bookings |
|
| 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
|
- 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)
|
- 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 & Discount System
|
||||||
|
|
||||||
**Loyalty Stamps:**
|
**Loyalty Stamps:**
|
||||||
|
|||||||
Reference in New Issue
Block a user