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" "github.com/jackc/pgx/v5" "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"` ReferralSavings float64 `json:"referralSavings"` ProfilePicURL *string `json:"profilePicUrl,omitempty"` DepositsRequired int `json:"deposits_required"` PreviousFirstName *string `json:"previousFirstName,omitempty"` PreviousLastName *string `json:"previousLastName,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"` // Name change history PreviousFirstName *string `json:"previousFirstName,omitempty"` PreviousLastName *string `json:"previousLastName,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"` CreatedAt time.Time `json:"created_at"` PreviousFirstName *string `json:"previousFirstName,omitempty"` PreviousLastName *string `json:"previousLastName,omitempty"` CompletedCount int `json:"completed_count"` } type UserListResponse struct { Users []UserListItem `json:"users"` Total int `json:"total"` Page int `json:"page"` PerPage int `json:"perPage"` TotalPages int `json:"totalPages"` NextCursor *string `json:"next_cursor,omitempty"` } // 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, deposits_required, (SELECT COUNT(*) FROM user_referrals WHERE referrer_id = users.id AND claimed_booking_id IS NOT NULL) AS referral_code_uses, (SELECT COALESCE(SUM(bd.discount_amount), 0) FROM booking_discounts bd WHERE bd.user_id = users.id AND bd.discount_source = 'referral') AS referral_savings 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.DepositsRequired, &user.ReferralCodeUses, &user.ReferralSavings, ) if err != nil { http.Error(w, "user not found", http.StatusNotFound) return } // Check if user has an unconsumed previous name (booking_id IS NULL means the name // change hasn't been "seen" via a completed booking yet). var prevFirstName, prevLastName sql.NullString err = db.DB.QueryRow(r.Context(), ` SELECT previous_first_name, previous_last_name FROM name_history WHERE user_id = $1 AND booking_id IS NULL ORDER BY changed_at ASC LIMIT 1 `, userID).Scan(&prevFirstName, &prevLastName) if err == nil && prevFirstName.Valid && prevLastName.Valid { if prevFirstName.String != user.FirstName || prevLastName.String != user.LastName { user.PreviousFirstName = &prevFirstName.String user.PreviousLastName = &prevLastName.String } } 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. // The DAV base URL is configured via DAV_BASE_URL — if unset, the update is skipped // silently (allowing the handler to work in dev environments without a DAV server). func updateCardDAV(userID, firstName, lastName, email, phone, dob, profilePicURL string) error { davBase := os.Getenv("DAV_BASE_URL") if davBase == "" { return nil } filename := fmt.Sprintf("%s.vcf", userID) url := fmt.Sprintf("%s/addressbooks/principals/default/default/%s", davBase, 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 current data for name change detection and CardDAV update var email string var dob sql.NullTime var profilePicURL sql.NullString var currentFirstName, currentLastName string err = db.DB.QueryRow(r.Context(), ` SELECT email, date_of_birth, profile_pic_url, n_first_name, n_last_name FROM users WHERE id = $1 `, userID).Scan(&email, &dob, &profilePicURL, ¤tFirstName, ¤tLastName) if err != nil { log.Printf("Failed to fetch user %s: %v", userID, err) http.Error(w, "failed to fetch user data", http.StatusInternalServerError) return } // Use a transaction for atomicity: insert name history + update user tx, err := db.DB.Begin(r.Context()) if err != nil { http.Error(w, "failed to begin transaction", http.StatusInternalServerError) return } defer tx.Rollback(r.Context()) // If first or last name changed (via user edit), track the old names in history nameChanged := currentFirstName != req.FirstName || currentLastName != req.LastName if nameChanged { _, err = tx.Exec(r.Context(), ` INSERT INTO name_history (user_id, previous_first_name, previous_last_name) VALUES ($1, $2, $3) `, userID, currentFirstName, currentLastName) if err != nil { log.Printf("Failed to insert name history for user %s: %v", userID, err) http.Error(w, "failed to record name change", http.StatusInternalServerError) return } } // Update DB _, err = tx.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 } if err := tx.Commit(r.Context()); err != nil { http.Error(w, "failed to commit transaction", 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, pgx.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 unconsumed name history (booking_id IS NULL = not yet "seen" via a completed booking) var prevFirstName, prevLastName sql.NullString err = db.DB.QueryRow(r.Context(), ` SELECT nh.previous_first_name, nh.previous_last_name FROM name_history nh WHERE nh.user_id = $1 AND nh.booking_id IS NULL ORDER BY nh.changed_at ASC LIMIT 1 `, userID).Scan(&prevFirstName, &prevLastName) if err == nil && prevFirstName.Valid && prevLastName.Valid { if prevFirstName.String != user.FirstName || prevLastName.String != user.LastName { user.PreviousFirstName = &prevFirstName.String user.PreviousLastName = &prevLastName.String } } // 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 } } // parseCursor splits a "createdAt|id" cursor string into its components. // GET /api/admin/users func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) { // Parse query parameters query := r.URL.Query() searchTerm := query.Get("q") cursorStr := query.Get("cursor") // Pagination parameters perPage := 10 if perPageStr := query.Get("per_page"); perPageStr != "" { if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 { perPage = pp } } page := 1 if pageStr := query.Get("page"); pageStr != "" { if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { page = p } } innerQuery := ` SELECT u.id, u.fn, u.email, u.phone, u.account_role, u.created_at, nh.previous_first_name, nh.previous_last_name, (SELECT COUNT(*) FROM bookings b WHERE b.user_id = u.id AND b.status = 'completed') AS completed_count FROM users u LEFT JOIN LATERAL ( SELECT previous_first_name, previous_last_name FROM name_history WHERE user_id = u.id AND booking_id IS NULL ORDER BY changed_at ASC LIMIT 1 ) nh ON true ` var listQuery string var listArgs []interface{} if searchTerm != "" { searchPattern := "%" + searchTerm + "%" innerWithWhere := `SELECT * FROM (` + innerQuery + ` WHERE (u.fn ILIKE $1 OR u.email ILIKE $1 OR u.phone ILIKE $1) ) sub` listArgs = []interface{}{searchPattern} if cursorStr != "" { cursorCount, cursorCreatedAt, cursorID, err := validators.ParseCursor3(cursorStr) if err != nil { http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest) return } listQuery = innerWithWhere + ` WHERE (completed_count, created_at, id) < ($2, $3, $4)` listArgs = append(listArgs, cursorCount, cursorCreatedAt, cursorID) } else { listQuery = innerWithWhere } listQuery += ` ORDER BY completed_count DESC, created_at DESC, id DESC LIMIT $` + strconv.Itoa(len(listArgs)+1) listArgs = append(listArgs, perPage+1) } else { innerNoWhere := `SELECT * FROM (` + innerQuery + `) sub` if cursorStr != "" { cursorCount, cursorCreatedAt, cursorID, err := validators.ParseCursor3(cursorStr) if err != nil { http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest) return } listQuery = innerNoWhere + ` WHERE (completed_count, created_at, id) < ($1, $2, $3)` listArgs = append(listArgs, cursorCount, cursorCreatedAt, cursorID) } else { listQuery = innerNoWhere } listQuery += ` ORDER BY completed_count DESC, created_at DESC, id DESC LIMIT $` + strconv.Itoa(len(listArgs)+1) listArgs = append(listArgs, perPage+1) } // 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 var total int // Compute total with a simple count query (no ORDER BY/LIMIT/HAVING). if searchTerm != "" { db.DB.QueryRow(r.Context(), `SELECT COUNT(DISTINCT u.id) 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`, "%"+searchTerm+"%").Scan(&total) } else { db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM users").Scan(&total) } for rows.Next() { var user UserListItem var prevFirstName, prevLastName sql.NullString var completedCount int err := rows.Scan( &user.ID, &user.FullName, &user.Email, &user.Phone, &user.AccountRole, &user.CreatedAt, &prevFirstName, &prevLastName, &completedCount, ) if err != nil { log.Printf("Failed to scan user row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } if prevFirstName.Valid && prevLastName.Valid { user.PreviousFirstName = &prevFirstName.String user.PreviousLastName = &prevLastName.String } user.CompletedCount = completedCount users = append(users, user) } // Handle empty results if users == nil { users = []UserListItem{} } var nextCursor *string if len(users) > perPage { users = users[:perPage] last := users[len(users)-1] cursor := fmt.Sprintf("%d|%s|%s", last.CompletedCount, last.CreatedAt.Format(time.RFC3339Nano), last.ID) nextCursor = &cursor } // 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, NextCursor: nextCursor, } 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=6,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) < 6 { http.Error(w, "password must be at least 6 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, pgx.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 } // Revoke all existing tokens by invalidating the current JTI for this user // This forces the user to re-authenticate after changing their password log.Printf("Password changed for user %s - existing sessions should re-authenticate", userID) 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, pgx.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 (CHAR(12) hex) 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) bucket := getEnv("S3_PROFILE_PICS_BUCKET", "crussell-profile-pics") var oldURL sql.NullString err = db.DB.QueryRow(r.Context(), `SELECT profile_pic_url FROM users WHERE id = $1`, userID).Scan(&oldURL) if err == nil && oldURL.Valid && oldURL.String != "" { if delErr := s3.Client.Delete(r.Context(), bucket, key); delErr != nil { log.Printf("Warning: Failed to delete old profile picture for user %s: %v", userID, delErr) } } 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 } 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, pgx.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) }