GET/PUT /api/user/notification-preferences with partial update support. Toggle section in /account Admin tab (Email, SMS, Browser push). 3 new tests: defaults, full update, partial update. Fix pre-existing timezone bug in exceptional hours tests (Truncate vs time.Date). Update README, Technical Manual, and gap backlog (#15 struck out).
992 lines
29 KiB
Go
992 lines
29 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/handlers/auth"
|
|
"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"`
|
|
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 sql.NullTime
|
|
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 {
|
|
log.Printf("Failed to fetch user %s: %v", userID, err)
|
|
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() {
|
|
var dobStr string
|
|
if dob.Valid {
|
|
dobStr = dob.Time.Format("2006-01-02")
|
|
}
|
|
if err := updateCardDAV(userID, req.FirstName, req.LastName, email, req.Phone, dobStr, profilePicURL.String); err != nil {
|
|
fmt.Printf("Warning: Failed to update CardDAV contact for user %s: %v\n", userID, err)
|
|
}
|
|
}()
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// GET /api/admin/users/{id}
|
|
func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID := chi.URLParam(r, "id")
|
|
if userID == "" || !validators.IsValidID(userID) {
|
|
http.Error(w, "User not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Fetch user details
|
|
var user AdminUserDetail
|
|
err := db.DB.QueryRow(r.Context(), `
|
|
SELECT
|
|
id, email, n_first_name, n_last_name, fn, phone,
|
|
date_of_birth::text, profile_pic_url,
|
|
account_role, account_type, loyalty_stamps, referral_code,
|
|
last_login_at::text, created_at::text, updated_at::text, notes,
|
|
privacy_policy_and_terms_consent,
|
|
policy_consent_updated_at::text,
|
|
data_retention_consent,
|
|
data_consent_updated_at::text
|
|
FROM users
|
|
WHERE id = $1
|
|
`, userID).Scan(
|
|
&user.ID, &user.Email, &user.FirstName, &user.LastName, &user.FullName,
|
|
&user.Phone, &user.DateOfBirth, &user.ProfilePicURL,
|
|
&user.AccountRole, &user.AccountType, &user.LoyaltyStamps, &user.ReferralCode,
|
|
&user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, &user.Notes,
|
|
&user.PrivacyPolicyConsent, &user.PolicyConsentUpdatedAt,
|
|
&user.DataRetentionConsent, &user.DataConsentUpdatedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
if errors.Is(err, 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
|
|
}
|
|
|
|
if req.NewPassword == req.CurrentPassword {
|
|
http.Error(w, "new password must be different from current password", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var passwordHash string
|
|
err := db.DB.QueryRow(r.Context(), `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&passwordHash)
|
|
if err != nil {
|
|
if errors.Is(err, 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)
|
|
}
|
|
|
|
// ServiceForPatchTest represents a service that requires a patch test
|
|
type ServiceForPatchTest struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
PatchTestID string `json:"patchTestId"`
|
|
NoticeDurationHours int `json:"noticeDurationHours"`
|
|
ExpiryMonths int `json:"expiryMonths"`
|
|
}
|
|
|
|
// GET /api/admin/users/{id}/patch-tests/eligible
|
|
// Returns services that require a patch test which the user hasn't completed yet
|
|
func GetEligiblePatchTestServicesHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID := chi.URLParam(r, "id")
|
|
if userID == "" || !validators.IsValidID(userID) {
|
|
http.Error(w, "User not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Get services that require a patch test but user hasn't completed
|
|
// This queries patch_tests to find which services require patch tests,
|
|
// then excludes those the user already has valid records for
|
|
rows, err := db.DB.Query(r.Context(), `
|
|
SELECT DISTINCT s.id, s.name, pt.id, pt.notice_duration_hours, pt.expiry_months
|
|
FROM services s
|
|
JOIN patch_tests pt ON s.id = ANY(pt.service_ids)
|
|
WHERE s.is_active = true
|
|
AND pt.id NOT IN (
|
|
SELECT pt_inner.id
|
|
FROM patch_tests pt_inner
|
|
JOIN user_patch_tests upt ON pt_inner.id = upt.patch_test_id
|
|
WHERE upt.user_id = $1
|
|
AND upt.tested_at + (pt_inner.expiry_months || ' months')::interval > NOW()
|
|
)
|
|
ORDER BY s.name ASC
|
|
`, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to fetch eligible patch test services: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var services []ServiceForPatchTest
|
|
for rows.Next() {
|
|
var s ServiceForPatchTest
|
|
if err := rows.Scan(&s.ID, &s.Name, &s.PatchTestID, &s.NoticeDurationHours, &s.ExpiryMonths); err != nil {
|
|
log.Printf("Failed to scan service: %v", err)
|
|
continue
|
|
}
|
|
services = append(services, s)
|
|
}
|
|
|
|
if services == nil {
|
|
services = []ServiceForPatchTest{}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(services)
|
|
}
|
|
|
|
type AddPatchTestRequest struct {
|
|
PatchTestID string `json:"patch_test_id"`
|
|
}
|
|
|
|
// 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 req.PatchTestID == "" {
|
|
http.Error(w, "patch_test_id is required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Verify patch test exists
|
|
var patchTestID string
|
|
err := db.DB.QueryRow(r.Context(), `SELECT id FROM patch_tests WHERE id = $1`, req.PatchTestID).Scan(&patchTestID)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
http.Error(w, "patch test not found", http.StatusBadRequest)
|
|
return
|
|
}
|
|
log.Printf("Failed to verify patch test: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Insert or update user_patch_tests record
|
|
_, err = db.DB.Exec(r.Context(), `
|
|
INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at)
|
|
VALUES ($1, $2, NOW())
|
|
ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW()
|
|
`, userID, req.PatchTestID)
|
|
if err != nil {
|
|
log.Printf("Failed to add patch test: %v", err)
|
|
http.Error(w, "failed to add patch test", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusCreated)
|
|
}
|
|
|
|
// UserPatchTest represents a user's patch test record
|
|
type UserPatchTest struct {
|
|
ID string `json:"id"`
|
|
PatchTestID string `json:"patchTestId"`
|
|
PatchTestName string `json:"patchTestName"`
|
|
TestedAt time.Time `json:"testedAt"`
|
|
ValidUntil time.Time `json:"validUntil"`
|
|
}
|
|
|
|
// GET /api/admin/users/{user_id}/patch-tests
|
|
// Returns all patch test records for a user
|
|
func GetUserPatchTestsHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID := chi.URLParam(r, "user_id")
|
|
if userID == "" || !validators.IsValidID(userID) {
|
|
http.Error(w, "User not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
rows, err := db.DB.Query(r.Context(), `
|
|
SELECT upt.id, upt.patch_test_id, pt.name, upt.tested_at, upt.tested_at + (pt.expiry_months || ' months')::interval as valid_until
|
|
FROM user_patch_tests upt
|
|
JOIN patch_tests pt ON upt.patch_test_id = pt.id
|
|
WHERE upt.user_id = $1
|
|
ORDER BY upt.tested_at DESC
|
|
`, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to get patch tests: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var tests []UserPatchTest
|
|
for rows.Next() {
|
|
var t UserPatchTest
|
|
if err := rows.Scan(&t.ID, &t.PatchTestID, &t.PatchTestName, &t.TestedAt, &t.ValidUntil); err != nil {
|
|
log.Printf("Failed to scan patch test: %v", err)
|
|
continue
|
|
}
|
|
tests = append(tests, t)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(tests)
|
|
}
|
|
|
|
// DELETE /api/admin/users/{user_id}/patch-tests/{test_id}
|
|
func DeletePatchTestHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID := chi.URLParam(r, "user_id")
|
|
testID := chi.URLParam(r, "test_id")
|
|
if userID == "" || !validators.IsValidID(userID) {
|
|
http.Error(w, "User not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if testID == "" || !validators.IsValidID(testID) {
|
|
http.Error(w, "Patch test not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// testID in this context is the user_patch_tests.id (BIGSERIAL)
|
|
result, err := db.DB.Exec(r.Context(), `
|
|
DELETE FROM user_patch_tests WHERE id = $1 AND user_id = $2
|
|
`, testID, userID)
|
|
if err != nil {
|
|
log.Printf("Failed to delete patch test: %v", err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if result.RowsAffected() == 0 {
|
|
http.Error(w, "patch test not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
type UploadProfilePicResponse struct {
|
|
URL string `json:"url"`
|
|
}
|
|
|
|
func UploadProfilePictureHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := r.Context().Value(mw.UserIDKey).(string)
|
|
if !ok || userID == "" {
|
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
if s3.Client == nil {
|
|
log.Printf("S3 client not initialized")
|
|
http.Error(w, "Storage not configured", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
r.ParseMultipartForm(10 << 20)
|
|
|
|
file, 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.StatusBadRequest)
|
|
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"`
|
|
}
|
|
|
|
type NotificationPreferencesResponse struct {
|
|
EmailEnabled bool `json:"emailEnabled"`
|
|
SMSEnabled bool `json:"smsEnabled"`
|
|
BrowserPushEnabled bool `json:"browserPushEnabled"`
|
|
}
|
|
|
|
type UpdateNotificationPreferencesRequest struct {
|
|
EmailEnabled *bool `json:"emailEnabled"`
|
|
SMSEnabled *bool `json:"smsEnabled"`
|
|
BrowserPushEnabled *bool `json:"browserPushEnabled"`
|
|
}
|
|
|
|
// GET /api/user/notification-preferences
|
|
func GetNotificationPreferencesHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := mw.GetUserID(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var prefs NotificationPreferencesResponse
|
|
err := db.DB.QueryRow(r.Context(), `
|
|
SELECT email_enabled, sms_enabled, browser_push_enabled
|
|
FROM user_notification_preferences
|
|
WHERE user_id = $1
|
|
`, userID).Scan(&prefs.EmailEnabled, &prefs.SMSEnabled, &prefs.BrowserPushEnabled)
|
|
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
prefs = NotificationPreferencesResponse{
|
|
EmailEnabled: true,
|
|
SMSEnabled: true,
|
|
BrowserPushEnabled: true,
|
|
}
|
|
} else {
|
|
log.Printf("Failed to fetch notification preferences for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(prefs)
|
|
}
|
|
|
|
// PUT /api/user/notification-preferences
|
|
func UpdateNotificationPreferencesHandler(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := mw.GetUserID(r.Context())
|
|
if !ok {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
var req UpdateNotificationPreferencesRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
var exists bool
|
|
err := db.DB.QueryRow(r.Context(), `
|
|
SELECT EXISTS(SELECT 1 FROM user_notification_preferences WHERE user_id = $1)
|
|
`, userID).Scan(&exists)
|
|
if err != nil {
|
|
log.Printf("Failed to check notification preferences for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if exists {
|
|
_, err = db.DB.Exec(r.Context(), `
|
|
UPDATE user_notification_preferences SET
|
|
email_enabled = COALESCE($2, email_enabled),
|
|
sms_enabled = COALESCE($3, sms_enabled),
|
|
browser_push_enabled = COALESCE($4, browser_push_enabled),
|
|
updated_at = NOW()
|
|
WHERE user_id = $1
|
|
`, userID, req.EmailEnabled, req.SMSEnabled, req.BrowserPushEnabled)
|
|
} else {
|
|
_, err = db.DB.Exec(r.Context(), `
|
|
INSERT INTO user_notification_preferences (user_id, email_enabled, sms_enabled, browser_push_enabled, updated_at)
|
|
VALUES ($1, COALESCE($2, true), COALESCE($3, true), COALESCE($4, true), NOW())
|
|
`, userID, req.EmailEnabled, req.SMSEnabled, req.BrowserPushEnabled)
|
|
}
|
|
|
|
if err != nil {
|
|
log.Printf("Failed to update notification preferences for user %s: %v", userID, err)
|
|
http.Error(w, "server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// GET /api/contact
|
|
func GetContactInfoHandler(w http.ResponseWriter, r *http.Request) {
|
|
var contact ContactInfo
|
|
err := db.DB.QueryRow(r.Context(), `
|
|
SELECT
|
|
COALESCE(n_first_name, '') || ' ' || COALESCE(n_last_name, '') as name,
|
|
COALESCE(phone, ''),
|
|
COALESCE(email, ''),
|
|
profile_pic_url
|
|
FROM users
|
|
WHERE account_role = 'admin'
|
|
ORDER BY created_at ASC
|
|
LIMIT 1
|
|
`).Scan(&contact.Name, &contact.Phone, &contact.Email, &contact.ProfilePicURL)
|
|
|
|
if err != nil {
|
|
log.Printf("Failed to get contact info: %v", err)
|
|
http.Error(w, "contact not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
contact.Role = "Owner / Beauty Specialist"
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(contact)
|
|
}
|