package user import ( "bytes" "encoding/json" "fmt" "net/http" "regexp" "strings" "time" "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"` ProfilePicURL *string `json:"profilePicUrl,omitempty"` } type UpdateProfileRequest struct { FirstName string `json:"firstName"` LastName string `json:"lastName"` Phone string `json:"phone"` } // 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 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, ) 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) }