Files
Crussell/backend/handlers/user/profile.go
T
popertots 970cc5554d feat: add email verification, profile pictures, deposits, and calendar
export
Backend:
- Add email verification code generation and verification endpoints
- Add profile picture upload with S3 storage and image processing
- Add deposit_required field to users with 48h advance booking
  requirement
- Add loyalty stamps that accumulate on completed bookings
- Auto-transition bookings: confirmed → in_progress → completed
- Add booking cancellation handler with no-show detection
- Add ICS calendar file download endpoint for bookings
- Sync bookings to CalDAV on confirmation
  Frontend:
- Add schedule page route
- Add avatar and image-cropper UI components
- Update shadcn-svelte components (button, dialog)
- Add "Add to Calendar" button in booking modal
  Database:
- Add verification_codes table
- Add profile_pic_url, loyalty_stamps, deposits_required to users
- Various schema updates
2026-02-21 18:48:29 +00:00

870 lines
25 KiB
Go

package user
import (
"bytes"
"database/sql"
"encoding/json"
"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/s3"
"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"`
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"`
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")
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
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 {
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, 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 == "" {
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 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"`
NewPassword string `json:"new_password"`
}
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 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
}
var passwordHash string
err := db.DB.QueryRow(r.Context(), `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&passwordHash)
if err != nil {
if 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)
}
type ServiceForPatchTest struct {
ID string `json:"id"`
Name string `json:"name"`
PatchTestDurationHours int `json:"patchTestDurationHours"`
}
// GET /api/admin/users/{id}/patch-tests/eligible
func GetEligiblePatchTestServicesHandler(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "id")
if userID == "" {
http.Error(w, "User ID is required", http.StatusBadRequest)
return
}
// Debug: count services with patch test
var totalWithPatchTest int
err := db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM services WHERE is_active = true AND patch_test_duration_hours > 0`).Scan(&totalWithPatchTest)
if err != nil {
log.Printf("Debug: failed to count patch test services: %v", err)
}
log.Printf("Debug: userID=%s, services with patch_test=%d", userID, totalWithPatchTest)
rows, err := db.DB.Query(r.Context(), `
SELECT s.id, s.name, s.patch_test_duration_hours
FROM services s
WHERE s.is_active = true AND s.patch_test_duration_hours > 0
AND s.id NOT IN (
SELECT service_id FROM user_service_patch_tests WHERE user_id = $1
)
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.PatchTestDurationHours); 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 {
ServiceID string `json:"service_id"`
}
// POST /api/admin/users/{id}/patch-tests
func AddPatchTestHandler(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "id")
if userID == "" {
http.Error(w, "User ID is required", http.StatusBadRequest)
return
}
var req AddPatchTestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
if req.ServiceID == "" {
http.Error(w, "service_id is required", http.StatusBadRequest)
return
}
var patchTestHours int
err := db.DB.QueryRow(r.Context(), `SELECT patch_test_duration_hours FROM services WHERE id = $1 AND is_active = true AND patch_test_duration_hours > 0`, req.ServiceID).Scan(&patchTestHours)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "service not found or does not require patch test", http.StatusBadRequest)
return
}
log.Printf("Failed to verify service: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
_, err = db.DB.Exec(r.Context(), `
INSERT INTO user_service_patch_tests (user_id, service_id, last_time)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id, service_id) DO UPDATE SET last_time = NOW()
`, userID, req.ServiceID)
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)
}
type UserPatchTest struct {
ID string `json:"id"`
ServiceID string `json:"serviceId"`
ServiceName string `json:"serviceName"`
LastTime time.Time `json:"lastTime"`
}
func GetUserPatchTestsHandler(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "user_id")
if userID == "" {
http.Error(w, "User ID is required", http.StatusBadRequest)
return
}
rows, err := db.DB.Query(r.Context(), `
SELECT p.id, p.service_id, s.name, p.last_time
FROM user_service_patch_tests p
JOIN services s ON p.service_id = s.id
WHERE p.user_id = $1
ORDER BY p.last_time 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.ServiceID, &t.ServiceName, &t.LastTime); 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)
}
func DeletePatchTestHandler(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "user_id")
testID := chi.URLParam(r, "test_id")
if userID == "" || testID == "" {
http.Error(w, "User ID and Test ID are required", http.StatusBadRequest)
return
}
result, err := db.DB.Exec(r.Context(), `
DELETE FROM user_service_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, header, 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()
ext := ".jpg"
if idx := strings.LastIndex(header.Filename, "."); idx != -1 {
ext = strings.ToLower(header.Filename[idx:])
}
key := fmt.Sprintf("profiles/%s%s", userID, ext)
fileBytes, err := io.ReadAll(file)
if err != nil {
log.Printf("Failed to read file: %v", err)
http.Error(w, "Failed to read file", http.StatusInternalServerError)
return
}
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)); 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"`
}
// 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)
}