package user import ( "bytes" "database/sql" "encoding/json" "fmt" "log" "net/http" "regexp" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "golang.org/x/text/cases" "golang.org/x/text/language" "crussell/db" "crussell/handlers/auth" "crussell/mw" ) 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"` LastName string `json:"lastName"` Phone string `json:"phone"` } 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"` // 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"` } 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 string) error { // Use user ID as filename - consistent with registration filename := fmt.Sprintf("%s.vcf", userID) url := fmt.Sprintf("http://nginx/dav/addressbooks/principals/default/default/%s", filename) // Create vCard with user ID as UID (no need to fetch existing) timestamp := time.Now().UTC().Format("20060102T150405Z") uid := fmt.Sprintf("%s@example.com", userID) 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 REV:%s END:VCARD`, uid, firstName, lastName, lastName, firstName, email, phone, dob, 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") req.SetBasicAuth("admin", "admin") 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 } // 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 time.Time err = db.DB.QueryRow(r.Context(), ` SELECT email, date_of_birth FROM users WHERE id = $1 `, userID).Scan(&email, &dob) if err != nil { 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() { dobStr := dob.Format("2006-01-02") if err := updateCardDAV(userID, req.FirstName, req.LastName, email, req.Phone, dobStr); 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 == "" { http.Error(w, "User ID is required", http.StatusBadRequest) 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 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 id, fn, email, phone FROM users WHERE fn ILIKE $1 OR email ILIKE $1 OR phone ILIKE $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 ` listArgs = []interface{}{searchPattern, perPage, offset} } else { // No search - get all users countQuery = `SELECT COUNT(*) FROM users` countArgs = []interface{}{} listQuery = ` SELECT id, fn, email, phone FROM users ORDER BY 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) 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 } }