package user import ( "bytes" "database/sql" "encoding/json" "errors" "fmt" "io" "log" "net/http" "os" "regexp" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/kovidgoyal/imaging" "golang.org/x/crypto/bcrypt" "golang.org/x/text/cases" "golang.org/x/text/language" "crussell/db" "crussell/handlers/auth" "crussell/internal/images" "crussell/internal/s3" "crussell/internal/validators" "crussell/mw" ) func getEnv(key, fallback string) string { if val := os.Getenv(key); val != "" { return val } return fallback } var titleCaser = cases.Title(language.English) type UserProfile struct { ID string `json:"id"` Email string `json:"email"` FirstName string `json:"firstName"` LastName string `json:"lastName"` Phone *string `json:"phone,omitempty"` DateOfBirth *string `json:"dateOfBirth,omitempty"` Role string `json:"role"` LoyaltyStamps int `json:"loyaltyStamps"` ReferralCode string `json:"referralCode"` ReferralCodeUses int `json:"referralCodeUses"` ProfilePicURL *string `json:"profilePicUrl,omitempty"` } type UpdateProfileRequest struct { FirstName string `json:"firstName" validate:"required,min=1,max=50"` LastName string `json:"lastName" validate:"required,min=1,max=50"` Phone string `json:"phone" validate:"required"` } type AdminUserDetail struct { ID string `json:"id"` Email *string `json:"email,omitempty"` FirstName string `json:"firstName"` LastName string `json:"lastName"` FullName string `json:"fullName"` Phone *string `json:"phone,omitempty"` DateOfBirth *string `json:"dateOfBirth,omitempty"` ProfilePicURL *string `json:"profilePicUrl,omitempty"` AccountRole string `json:"accountRole"` AccountType string `json:"accountType"` LoyaltyStamps int `json:"loyaltyStamps"` ReferralCode string `json:"referralCode"` ReferralCodeUses int `json:"referralCodeUses"` LastLoginAt *string `json:"lastLoginAt,omitempty"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"` // GDPR consent fields PrivacyPolicyConsent bool `json:"privacyPolicyConsent"` PolicyConsentUpdatedAt *string `json:"policyConsentUpdatedAt,omitempty"` DataRetentionConsent bool `json:"dataRetentionConsent"` DataConsentUpdatedAt *string `json:"dataConsentUpdatedAt,omitempty"` // Social logins SocialLogins []SocialLogin `json:"socialLogins,omitempty"` } type SocialLogin struct { Provider string `json:"provider"` CreatedAt string `json:"createdAt"` } type UserListItem struct { ID string `json:"id"` FullName string `json:"fullName"` Email *string `json:"email,omitempty"` Phone *string `json:"phone,omitempty"` AccountRole string `json:"account_role"` } type UserListResponse struct { Users []UserListItem `json:"users"` Total int `json:"total"` Page int `json:"page"` PerPage int `json:"perPage"` TotalPages int `json:"totalPages"` } // GET /api/user/profile func GetProfileHandler(w http.ResponseWriter, r *http.Request) { userID, ok := mw.GetUserID(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var user UserProfile err := db.DB.QueryRow(r.Context(), ` SELECT id, email, n_first_name, n_last_name, phone, date_of_birth::text, account_role, loyalty_stamps, referral_code, profile_pic_url, (SELECT COUNT(*) FROM user_referrals WHERE referrer_id = users.id AND claimed_booking_id IS NOT NULL) AS referral_code_uses FROM users WHERE id = $1 `, userID).Scan( &user.ID, &user.Email, &user.FirstName, &user.LastName, &user.Phone, &user.DateOfBirth, &user.Role, &user.LoyaltyStamps, &user.ReferralCode, &user.ProfilePicURL, &user.ReferralCodeUses, ) if err != nil { http.Error(w, "user not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) } // PUT /api/user/profile // updateCardDAV updates an existing contact in SabreDAV using user ID func updateCardDAV(userID, firstName, lastName, email, phone, dob, profilePicURL string) error { filename := fmt.Sprintf("%s.vcf", userID) url := fmt.Sprintf("http://nginx/dav/addressbooks/principals/default/default/%s", filename) timestamp := time.Now().UTC().Format("20060102T150405Z") uid := fmt.Sprintf("%s@example.com", userID) var photoLine string if profilePicURL != "" { photoLine = fmt.Sprintf("PHOTO;VALUE=URI:%s", profilePicURL) } vcard := fmt.Sprintf(`BEGIN:VCARD VERSION:3.0 UID:%s FN:%s %s N:%s;%s;;; EMAIL;TYPE=INTERNET:%s TEL;TYPE=CELL:%s BDAY:%s %s REV:%s END:VCARD`, uid, firstName, lastName, lastName, firstName, email, phone, dob, photoLine, timestamp) // PUT updated vCard req, err := http.NewRequest("PUT", url, bytes.NewBufferString(vcard)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "text/vcard; charset=utf-8") davPassword := os.Getenv("DAV_ADMIN_PASSWORD") if davPassword == "" { davPassword = "admin" } req.SetBasicAuth("admin", davPassword) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return fmt.Errorf("failed to update CardDAV: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("CardDAV returned status: %d", resp.StatusCode) } return nil } // PUT /api/user/profile func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) { userID, _ := mw.GetUserID(r.Context()) var req UpdateProfileRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } if err := validators.Validate.Struct(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Normalize input req.FirstName = strings.TrimSpace(req.FirstName) req.LastName = strings.TrimSpace(req.LastName) req.Phone = strings.TrimSpace(req.Phone) // Required fields if req.FirstName == "" || req.LastName == "" || req.Phone == "" { http.Error(w, "first name, last name and phone are required", http.StatusBadRequest) return } // Validate names (unicode letters, spaces, hyphen, apostrophe, dot) nameRegex := regexp.MustCompile(`^[\p{L}\p{M}\s\-'\.]+$`) if !nameRegex.MatchString(req.FirstName) || !nameRegex.MatchString(req.LastName) { http.Error(w, "invalid characters in name", http.StatusBadRequest) return } // Validate lengths if len(req.FirstName) < 1 || len(req.FirstName) > 50 { http.Error(w, "first name must be 1-50 characters", http.StatusBadRequest) return } if len(req.LastName) < 1 || len(req.LastName) > 50 { http.Error(w, "last name must be 1-50 characters", http.StatusBadRequest) return } // Normalize phone (strip spaces, hyphens, brackets) req.Phone = strings.Map(func(r rune) rune { if (r >= '0' && r <= '9') || r == '+' { return r } return -1 }, req.Phone) // Validate UK phone number phone, err := auth.ValidateUKPhoneNumber(req.Phone) if err != nil { http.Error(w, "invalid phone number format", http.StatusBadRequest) return } req.Phone = strings.TrimSpace(phone) // Title case names req.FirstName = titleCaser.String(strings.ToLower(req.FirstName)) req.LastName = titleCaser.String(strings.ToLower(req.LastName)) // Fetch user's email and DOB for CardDAV update var email string var dob sql.NullTime var profilePicURL sql.NullString err = db.DB.QueryRow(r.Context(), ` SELECT email, date_of_birth, profile_pic_url FROM users WHERE id = $1 `, userID).Scan(&email, &dob, &profilePicURL) if err != nil { log.Printf("Failed to fetch user %s: %v", userID, err) http.Error(w, "failed to fetch user data", http.StatusInternalServerError) return } // Update DB _, err = db.DB.Exec(r.Context(), ` UPDATE users SET n_first_name = $1, n_last_name = $2, phone = $3, updated_at = NOW() WHERE id = $4 `, req.FirstName, req.LastName, req.Phone, userID) if err != nil { http.Error(w, "update failed", http.StatusInternalServerError) return } // Update CardDAV (non-blocking) go func() { var dobStr string if dob.Valid { dobStr = dob.Time.Format("2006-01-02") } if err := updateCardDAV(userID, req.FirstName, req.LastName, email, req.Phone, dobStr, profilePicURL.String); err != nil { fmt.Printf("Warning: Failed to update CardDAV contact for user %s: %v\n", userID, err) } }() w.WriteHeader(http.StatusOK) } // GET /api/admin/users/{id} func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "id") if userID == "" || !validators.IsValidID(userID) { http.Error(w, "User not found", http.StatusNotFound) return } // Fetch user details var user AdminUserDetail err := db.DB.QueryRow(r.Context(), ` SELECT id, email, n_first_name, n_last_name, fn, phone, date_of_birth::text, profile_pic_url, account_role, account_type, loyalty_stamps, referral_code, last_login_at::text, created_at::text, updated_at::text, notes, privacy_policy_and_terms_consent, policy_consent_updated_at::text, data_retention_consent, data_consent_updated_at::text FROM users WHERE id = $1 `, userID).Scan( &user.ID, &user.Email, &user.FirstName, &user.LastName, &user.FullName, &user.Phone, &user.DateOfBirth, &user.ProfilePicURL, &user.AccountRole, &user.AccountType, &user.LoyaltyStamps, &user.ReferralCode, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, &user.Notes, &user.PrivacyPolicyConsent, &user.PolicyConsentUpdatedAt, &user.DataRetentionConsent, &user.DataConsentUpdatedAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "User not found", http.StatusNotFound) return } log.Printf("Failed to fetch user %s: %v", userID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Fetch referral code uses count err = db.DB.QueryRow(r.Context(), ` SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1 AND claimed_booking_id IS NOT NULL `, userID).Scan(&user.ReferralCodeUses) if err != nil { log.Printf("Failed to fetch referral code uses for user %s: %v", userID, err) user.ReferralCodeUses = 0 } // Fetch social logins socialRows, err := db.DB.Query(r.Context(), ` SELECT provider, created_at::text FROM user_social_logins WHERE user_id = $1 ORDER BY created_at ASC `, userID) if err != nil { log.Printf("Failed to fetch social logins for user %s: %v", userID, err) } else { defer socialRows.Close() var socialLogins []SocialLogin for socialRows.Next() { var sl SocialLogin if err := socialRows.Scan(&sl.Provider, &sl.CreatedAt); err != nil { log.Printf("Failed to scan social login for user %s: %v", userID, err) continue } socialLogins = append(socialLogins, sl) } if len(socialLogins) > 0 { user.SocialLogins = socialLogins } } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(user); err != nil { log.Printf("Failed to encode user response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } // GET /api/admin/users func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) { // Parse query parameters query := r.URL.Query() searchTerm := query.Get("q") // Pagination parameters page := 1 perPage := 10 if pageStr := query.Get("page"); pageStr != "" { if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { page = p } } if perPageStr := query.Get("per_page"); perPageStr != "" { if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 { perPage = pp } } offset := (page - 1) * perPage // Build query based on whether search is provided var countQuery string var listQuery string var countArgs []interface{} var listArgs []interface{} if searchTerm != "" { // Search in name, email, or phone searchPattern := "%" + searchTerm + "%" countQuery = ` SELECT COUNT(*) FROM users WHERE fn ILIKE $1 OR email ILIKE $1 OR phone ILIKE $1 ` countArgs = []interface{}{searchPattern} listQuery = ` SELECT u.id, u.fn, u.email, u.phone, u.account_role FROM users u LEFT JOIN bookings b ON u.id = b.user_id WHERE u.fn ILIKE $1 OR u.email ILIKE $1 OR u.phone ILIKE $1 GROUP BY u.id, u.fn, u.email, u.phone, u.account_role, u.created_at ORDER BY COUNT(b.id) DESC, u.created_at DESC LIMIT $2 OFFSET $3 ` listArgs = []interface{}{searchPattern, perPage, offset} } else { // No search - get all users, sorted by booking count countQuery = `SELECT COUNT(*) FROM users` countArgs = []interface{}{} listQuery = ` SELECT u.id, u.fn, u.email, u.phone, u.account_role FROM users u LEFT JOIN bookings b ON u.id = b.user_id GROUP BY u.id, u.fn, u.email, u.phone, u.account_role, u.created_at ORDER BY COUNT(b.id) DESC, u.created_at DESC LIMIT $1 OFFSET $2 ` listArgs = []interface{}{perPage, offset} } // Get total count var total int err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total) if err != nil { log.Printf("Failed to count users: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } // Get users list rows, err := db.DB.Query(r.Context(), listQuery, listArgs...) if err != nil { log.Printf("Failed to fetch users: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } defer rows.Close() var users []UserListItem for rows.Next() { var user UserListItem err := rows.Scan( &user.ID, &user.FullName, &user.Email, &user.Phone, &user.AccountRole, ) if err != nil { log.Printf("Failed to scan user row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } users = append(users, user) } // Handle empty results if users == nil { users = []UserListItem{} } // Calculate total pages totalPages := (total + perPage - 1) / perPage if totalPages == 0 { totalPages = 1 } response := UserListResponse{ Users: users, Total: total, Page: page, PerPage: perPage, TotalPages: totalPages, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(response); err != nil { log.Printf("Failed to encode users response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } } type ChangePasswordRequest struct { CurrentPassword string `json:"current_password" validate:"required,max=72"` NewPassword string `json:"new_password" validate:"required,min=8,max=72"` } func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) { userID, ok := mw.GetUserID(r.Context()) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var req ChangePasswordRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } if err := validators.Validate.Struct(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if req.CurrentPassword == "" || req.NewPassword == "" { http.Error(w, "current password and new password are required", http.StatusBadRequest) return } if len(req.NewPassword) < 8 { http.Error(w, "password must be at least 8 characters", http.StatusBadRequest) return } if len(req.NewPassword) > 72 { http.Error(w, "password must be less than 72 characters", http.StatusBadRequest) return } if req.NewPassword == req.CurrentPassword { http.Error(w, "new password must be different from current password", http.StatusBadRequest) return } var passwordHash string err := db.DB.QueryRow(r.Context(), `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&passwordHash) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "user not found", http.StatusNotFound) return } log.Printf("Failed to fetch password hash for user %s: %v", userID, err) http.Error(w, "server error", http.StatusInternalServerError) return } if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.CurrentPassword)); err != nil { http.Error(w, "current password is incorrect", http.StatusUnauthorized) return } newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { log.Printf("Failed to hash new password for user %s: %v", userID, err) http.Error(w, "server error", http.StatusInternalServerError) return } _, err = db.DB.Exec(r.Context(), `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, string(newHash), userID) if err != nil { log.Printf("Failed to update password for user %s: %v", userID, err) http.Error(w, "failed to update password", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) } // ServiceForPatchTest represents a service that requires a patch test type ServiceForPatchTest struct { ID string `json:"id"` Name string `json:"name"` PatchTestID string `json:"patchTestId"` NoticeDurationHours int `json:"noticeDurationHours"` ExpiryMonths int `json:"expiryMonths"` } // GET /api/admin/users/{id}/patch-tests/eligible // Returns services that require a patch test which the user hasn't completed yet func GetEligiblePatchTestServicesHandler(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "id") if userID == "" || !validators.IsValidID(userID) { http.Error(w, "User not found", http.StatusNotFound) return } // Get services that require a patch test but user hasn't completed // This queries patch_tests to find which services require patch tests, // then excludes those the user already has valid records for rows, err := db.DB.Query(r.Context(), ` SELECT DISTINCT s.id, s.name, pt.id, pt.notice_duration_hours, pt.expiry_months FROM services s JOIN patch_tests pt ON s.id = ANY(pt.service_ids) WHERE s.is_active = true AND pt.id NOT IN ( SELECT pt_inner.id FROM patch_tests pt_inner JOIN user_patch_tests upt ON pt_inner.id = upt.patch_test_id WHERE upt.user_id = $1 AND upt.tested_at + (pt_inner.expiry_months || ' months')::interval > NOW() ) ORDER BY s.name ASC `, userID) if err != nil { log.Printf("Failed to fetch eligible patch test services: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } defer rows.Close() var services []ServiceForPatchTest for rows.Next() { var s ServiceForPatchTest if err := rows.Scan(&s.ID, &s.Name, &s.PatchTestID, &s.NoticeDurationHours, &s.ExpiryMonths); err != nil { log.Printf("Failed to scan service: %v", err) continue } services = append(services, s) } if services == nil { services = []ServiceForPatchTest{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(services) } type AddPatchTestRequest struct { PatchTestID string `json:"patch_test_id" validate:"required"` } // POST /api/admin/users/{id}/patch-tests // Records that a user has taken a patch test func AddPatchTestHandler(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "id") if userID == "" || !validators.IsValidID(userID) { http.Error(w, "User not found", http.StatusNotFound) return } var req AddPatchTestRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid request", http.StatusBadRequest) return } if err := validators.Validate.Struct(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if req.PatchTestID == "" { http.Error(w, "patch_test_id is required", http.StatusBadRequest) return } // Verify patch test exists var patchTestID string err := db.DB.QueryRow(r.Context(), `SELECT id FROM patch_tests WHERE id = $1`, req.PatchTestID).Scan(&patchTestID) if err != nil { if errors.Is(err, sql.ErrNoRows) { http.Error(w, "patch test not found", http.StatusBadRequest) return } log.Printf("Failed to verify patch test: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } // Insert or update user_patch_tests record _, err = db.DB.Exec(r.Context(), ` INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at) VALUES ($1, $2, NOW()) ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW() `, userID, req.PatchTestID) if err != nil { log.Printf("Failed to add patch test: %v", err) http.Error(w, "failed to add patch test", http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) } // UserPatchTest represents a user's patch test record type UserPatchTest struct { ID string `json:"id"` PatchTestID string `json:"patchTestId"` PatchTestName string `json:"patchTestName"` TestedAt time.Time `json:"testedAt"` ValidUntil time.Time `json:"validUntil"` } // GET /api/admin/users/{user_id}/patch-tests // Returns all patch test records for a user func GetUserPatchTestsHandler(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "user_id") if userID == "" || !validators.IsValidID(userID) { http.Error(w, "User not found", http.StatusNotFound) return } rows, err := db.DB.Query(r.Context(), ` SELECT upt.id, upt.patch_test_id, pt.name, upt.tested_at, upt.tested_at + (pt.expiry_months || ' months')::interval as valid_until FROM user_patch_tests upt JOIN patch_tests pt ON upt.patch_test_id = pt.id WHERE upt.user_id = $1 ORDER BY upt.tested_at DESC `, userID) if err != nil { log.Printf("Failed to get patch tests: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } defer rows.Close() var tests []UserPatchTest for rows.Next() { var t UserPatchTest if err := rows.Scan(&t.ID, &t.PatchTestID, &t.PatchTestName, &t.TestedAt, &t.ValidUntil); err != nil { log.Printf("Failed to scan patch test: %v", err) continue } tests = append(tests, t) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(tests) } // DELETE /api/admin/users/{user_id}/patch-tests/{test_id} func DeletePatchTestHandler(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "user_id") testID := chi.URLParam(r, "test_id") if userID == "" || !validators.IsValidID(userID) { http.Error(w, "User not found", http.StatusNotFound) return } if testID == "" || !validators.IsValidID(testID) { http.Error(w, "Patch test not found", http.StatusNotFound) return } // testID in this context is the user_patch_tests.id (BIGSERIAL) result, err := db.DB.Exec(r.Context(), ` DELETE FROM user_patch_tests WHERE id = $1 AND user_id = $2 `, testID, userID) if err != nil { log.Printf("Failed to delete patch test: %v", err) http.Error(w, "server error", http.StatusInternalServerError) return } if result.RowsAffected() == 0 { http.Error(w, "patch test not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) } type UploadProfilePicResponse struct { URL string `json:"url"` } func UploadProfilePictureHandler(w http.ResponseWriter, r *http.Request) { userID, ok := r.Context().Value(mw.UserIDKey).(string) if !ok || userID == "" { http.Error(w, "Authentication required", http.StatusUnauthorized) return } if s3.Client == nil { log.Printf("S3 client not initialized") http.Error(w, "Storage not configured", http.StatusInternalServerError) return } r.ParseMultipartForm(10 << 20) file, _, err := r.FormFile("file") if err != nil { log.Printf("Failed to get file: %v", err) http.Error(w, "No file provided", http.StatusBadRequest) return } defer file.Close() fileBytes, err := io.ReadAll(file) if err != nil { log.Printf("Failed to read file: %v", err) http.Error(w, "Failed to read file", http.StatusBadRequest) return } if _, err := images.ValidateImageBytes(fileBytes); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } ext := ".jpg" key := fmt.Sprintf("profiles/%s%s", userID, ext) fileBytes, err = processProfileImage(fileBytes) if err != nil { log.Printf("Failed to process image: %v", err) http.Error(w, "Failed to process image", http.StatusInternalServerError) return } bucket := getEnv("S3_PROFILE_PICS_BUCKET", "crussell-profile-pics") if err := s3.Client.Upload(r.Context(), bucket, key, bytes.NewReader(fileBytes), "image/jpeg"); err != nil { log.Printf("Failed to upload profile picture to S3: %v", err) http.Error(w, "Failed to upload image", http.StatusInternalServerError) return } url, err := s3.Client.GetURL(r.Context(), bucket, key) if err != nil { log.Printf("Failed to get URL: %v", err) http.Error(w, "Failed to get image URL", http.StatusInternalServerError) return } _, err = db.DB.Exec(r.Context(), `UPDATE users SET profile_pic_url = $1 WHERE id = $2`, url, userID) if err != nil { log.Printf("Failed to update user profile pic: %v", err) http.Error(w, "Failed to save profile picture", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(UploadProfilePicResponse{URL: url}) } func processProfileImage(data []byte) ([]byte, error) { img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true)) if err != nil { return nil, fmt.Errorf("failed to decode image: %w", err) } img = imaging.Thumbnail(img, 350, 350, imaging.Linear) var buf bytes.Buffer err = imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(85)) if err != nil { return nil, fmt.Errorf("failed to encode image: %w", err) } return buf.Bytes(), nil } type ContactInfo struct { Name string `json:"name"` Role string `json:"role"` Phone string `json:"phone"` Email string `json:"email"` 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 err := db.DB.QueryRow(r.Context(), ` SELECT COALESCE(n_first_name, '') || ' ' || COALESCE(n_last_name, '') as name, COALESCE(phone, ''), COALESCE(email, ''), profile_pic_url FROM users WHERE account_role = 'admin' ORDER BY created_at ASC LIMIT 1 `).Scan(&contact.Name, &contact.Phone, &contact.Email, &contact.ProfilePicURL) if err != nil { log.Printf("Failed to get contact info: %v", err) http.Error(w, "contact not found", http.StatusNotFound) return } contact.Role = "Owner / Beauty Specialist" w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(contact) }