Files
Crussell/backend/handlers/user/profile.go
T
popertots 8d9f72b9f0 feat: CardDAV profile sync writes through dav.Service (photo included, DAV_BASE_URL retired)
- profile.go updateCardDAV now writes directly to the shared dav_cards table via
  dav.Service (mirroring registration) instead of PUTting a vCard to
  DAV_BASE_URL over HTTP — the Go backend no longer needs the SabreDAV URL,
  only the sabredav PHP container uses the server-side credential.
- dav/types.go: ContactInput gains PhotoURL; GenerateVCard emits the
  PHOTO;VALUE=URI line when set, so the profile photo syncs into the address
  book. DAV_BASE_URL is retained in .env.example for reference only.
2026-08-22 00:34:50 +01:00

1310 lines
42 KiB
Go

package user
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"log/slog"
"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"
coreauth "crussell/auth"
"crussell/clock"
"crussell/db"
"crussell/handlers/auth"
"crussell/handlers/payments"
"crussell/internal/dav"
"crussell/internal/images"
"crussell/internal/s3"
"crussell/internal/validators"
"crussell/mw"
"github.com/jackc/pgx/v5"
)
func getEnv(key, fallback string) string {
if val := os.Getenv(key); val != "" {
return val
}
return fallback
}
// titleCase converts a name to Title Case. A fresh cases.Caser is created per
// call because golang.org/x/text/cases.Caser is NOT safe for concurrent use
// (it is a stateful transformer), and UpdateProfileHandler may run for two
// users at once. The package-level var previously shared one instance, which
// the race detector flagged (DATA RACE on titleCaser.String).
func titleCase(s string) string {
return cases.Title(language.English).String(strings.ToLower(s))
}
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"`
// Two-factor authentication state. TwoFactorRequired reflects whether the
// deployment enforces 2FA (REQUIRE_2FA + SQUARE_ENVIRONMENT), so the
// frontend can gate the online-card-payment UI.
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TwoFactorRequired bool `json:"twoFactorRequired"`
TwoFactorMethod *string `json:"twoFactorMethod"`
}
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"`
// Two-factor authentication state (admin recovery view)
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TwoFactorMethod *string `json:"twoFactorMethod,omitempty"`
TwoFactorLastUsedAt *string `json:"twoFactorLastUsedAt,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
var twoFactorMethod sql.NullString
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,
two_factor_enabled, two_factor_method,
(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.TwoFactorEnabled, &twoFactorMethod,
&user.ReferralCodeUses, &user.ReferralSavings,
)
if err != nil {
http.Error(w, "user not found", http.StatusNotFound)
return
}
user.TwoFactorRequired = payments.NewPaymentService().TwoFactorEnforced()
if twoFactorMethod.Valid {
user.TwoFactorMethod = &twoFactorMethod.String
}
// 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
}
}
if err := json.NewEncoder(w).Encode(user); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// PUT /api/user/profile
// updateCardDAV syncs the updated profile into the CardDAV address book.
// It writes directly to the shared dav_cards table via dav.Service (the same
// table the SabreDAV server serves), mirroring how registration creates
// contacts. Returns nil if no DAV backend is configured, so the handler works
// in dev environments without a DAV server.
func updateCardDAV(userID, firstName, lastName, email, phone, dob, profilePicURL string) error {
if dav.Service == nil {
return nil
}
input := dav.ContactInput{
UserID: userID,
FirstName: firstName,
LastName: lastName,
Email: email,
Phone: phone,
DOB: dob,
PhotoURL: profilePicURL,
}
uri := fmt.Sprintf("%s.vcf", userID)
if _, err := dav.Service.GetContactByURI(1, uri); err != nil {
return dav.Service.CreateContact(1, userID, input)
}
return dav.Service.UpdateContact(1, uri, input)
}
// 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 {
log.Printf("Failed to process request: %v", err)
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 = titleCase(req.FirstName)
req.LastName = titleCase(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 func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
// 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,
two_factor_enabled, two_factor_method, two_factor_last_used_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,
&user.TwoFactorEnabled, &user.TwoFactorMethod, &user.TwoFactorLastUsedAt,
)
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 []any
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 = []any{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 != "" {
if err := 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); err != nil {
log.Printf("Failed to scan filtered user count: %v", err)
}
} else {
if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM users").Scan(&total); err != nil {
log.Printf("Failed to scan user count: %v", err)
}
}
// 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 {
log.Printf("Failed to process request: %v", err)
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) < 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 func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
_, 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
}
// B9: actually revoke this session's access token AND every refresh token,
// inside the same transaction as the password update. A password change
// must invalidate all existing sessions — otherwise a stolen token (access
// or refresh) would survive the credential rotation.
//
// (a) Insert the current access token's JTI into revoked_jtis so it can
// never be reused. expires_at outlives the 1-hour token lifetime, so the
// revocation record cannot lapse while the token is still valid.
jti, _ := mw.GetJTI(r.Context())
if jti != "" {
if _, err := tx.Exec(r.Context(), `
INSERT INTO revoked_jtis (jti, expires_at) VALUES ($1, $2)
ON CONFLICT (jti) DO NOTHING
`, jti, clock.Now().Add(24*time.Hour)); err != nil {
log.Printf("Failed to revoke JTI for user %s: %v", userID, err)
http.Error(w, "failed to update password", http.StatusInternalServerError)
return
}
}
// (b) Delete all refresh tokens for the user so no rotated/stolen 90-day
// credential can mint a new access token after the password change.
if _, err := tx.Exec(r.Context(), `DELETE FROM refresh_tokens WHERE user_id = $1`, userID); err != nil {
log.Printf("Failed to revoke refresh tokens 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
}
// Finding 5: the DELETE above removed every refresh token the user held, but
// the in-memory family-alive cache would keep their access tokens passing
// verifyFamilyAlive for up to familyAliveCacheTTL. Drop the cached verdicts
// for all of the user's families so bound access tokens die on their next
// verification — the password change kills sessions immediately.
coreauth.InvalidateFamilyAliveByUser(userID)
// B9: the current JTI is now in revoked_jtis and every refresh token for
// the user has been deleted — existing sessions must re-authenticate.
log.Printf("Password changed for user %s - current JTI revoked and all refresh tokens deleted; existing sessions must 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{}
}
if err := json.NewEncoder(w).Encode(services); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
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 {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", 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 func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
_, 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
}
if err := json.NewEncoder(w).Encode(tests); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// 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 func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
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
}
// #nosec G120 — body size limited by limitBody middleware
if err := r.ParseMultipartForm(1 << 20); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
http.Error(w, "Profile picture too large", http.StatusRequestEntityTooLarge)
return
}
// Non-size parse errors: log but continue — form values may still be available
log.Printf("Warning: ParseMultipartForm: %v", err)
}
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 {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", 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 func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
_, 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
}
if err := json.NewEncoder(w).Encode(UploadProfilePicResponse{URL: url}); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
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
}
}
if err := json.NewEncoder(w).Encode(prefs); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// 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 func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
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"
if err := json.NewEncoder(w).Encode(contact); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}