Files
Crussell/backend/handlers/user/profile.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

1221 lines
38 KiB
Go

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/clock"
"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.Conn.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.Conn.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
}
}
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 := clock.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.Conn.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, &currentFirstName, &currentLastName)
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.Conn.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() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic recovered in CardDAV profile update: %v", r)
}
}()
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.Conn.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.Conn.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.Conn.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.Conn.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.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 id, fn, email, phone, account_role, created_at, previous_first_name, previous_last_name, completed_count 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 id, fn, email, phone, account_role, created_at, previous_first_name, previous_last_name, completed_count 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)
}
var users []UserListItem
var total int
// Compute total with a simple count query (no ORDER BY/LIMIT/HAVING).
// Run BEFORE the data query to avoid "conn busy" errors when routing
// through a per-test transaction (pgx.Tx does not support concurrent queries).
if searchTerm != "" {
db.Conn.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.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM users").Scan(&total)
}
// Get users list
rows, err := db.Conn.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()
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)
}
if err := rows.Err(); err != nil {
log.Printf("Row iteration error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// 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.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.Conn.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
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
_, err = tx.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
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", 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.Conn.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 err := rows.Err(); err != nil {
log.Printf("Row iteration error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if services == nil {
services = []ServiceForPatchTest{}
}
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.Conn.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
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
_, err = tx.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
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", 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.Conn.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)
}
if err := rows.Err(); err != nil {
log.Printf("Row iteration error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
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)
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
result, err := tx.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
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
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.Conn.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
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
_, err = tx.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
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
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.Conn.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
}
}
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.Conn.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
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
if exists {
_, err = tx.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 = tx.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
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal 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.Conn.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"
json.NewEncoder(w).Encode(contact)
}