feat(backend): add name history tracking, booking pagination, and CardDAV improvements
Name history system tracks user name changes and displays former names on bookings, appointments, and admin views until consumed by the first completed booking post-change. - Add name_history queries to today.go, bookings.go, manage.go, profile.go - Show previous first/last name on today appointments, pending approvals, booking details, edit requests, and admin user views - Paginate bookings by start_time instead of created_at (more intuitive ordering) - Add name change detection in UpdateProfileHandler with history insert - Support DAV_BASE_URL env var for configurable CardDAV endpoint - Add referral_savings to profile response - Add ParseCursor3 validator for user list cursor pagination - Consume name_history entries when a booking is completed (ProgressBookingHandler) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -22,6 +22,7 @@ import (
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"crussell/db"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"crussell/handlers/auth"
|
||||
"crussell/internal/images"
|
||||
"crussell/internal/s3"
|
||||
@@ -49,8 +50,11 @@ type UserProfile struct {
|
||||
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 {
|
||||
@@ -86,6 +90,10 @@ type AdminUserDetail struct {
|
||||
|
||||
// Social logins
|
||||
SocialLogins []SocialLogin `json:"socialLogins,omitempty"`
|
||||
|
||||
// Name change history
|
||||
PreviousFirstName *string `json:"previousFirstName,omitempty"`
|
||||
PreviousLastName *string `json:"previousLastName,omitempty"`
|
||||
}
|
||||
|
||||
type SocialLogin struct {
|
||||
@@ -94,12 +102,15 @@ type SocialLogin struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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 {
|
||||
@@ -125,13 +136,14 @@ func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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 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.LoyaltyStamps, &user.ReferralCode, &user.ProfilePicURL, &user.DepositsRequired, &user.ReferralCodeUses, &user.ReferralSavings,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -139,15 +151,38 @@ func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user has an unconsumed previous name (booking_id IS NULL means the name
|
||||
// change hasn't been "seen" via a completed booking yet).
|
||||
var prevFirstName, prevLastName sql.NullString
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT previous_first_name, previous_last_name
|
||||
FROM name_history
|
||||
WHERE user_id = $1 AND booking_id IS NULL
|
||||
ORDER BY changed_at ASC
|
||||
LIMIT 1
|
||||
`, userID).Scan(&prevFirstName, &prevLastName)
|
||||
if err == nil && prevFirstName.Valid && prevLastName.Valid {
|
||||
if prevFirstName.String != user.FirstName || prevLastName.String != user.LastName {
|
||||
user.PreviousFirstName = &prevFirstName.String
|
||||
user.PreviousLastName = &prevLastName.String
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user)
|
||||
}
|
||||
|
||||
// PUT /api/user/profile
|
||||
// updateCardDAV updates an existing contact in SabreDAV using user ID
|
||||
// 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("http://nginx/dav/addressbooks/principals/default/default/%s", filename)
|
||||
url := fmt.Sprintf("%s/addressbooks/principals/default/default/%s", davBase, filename)
|
||||
|
||||
timestamp := time.Now().UTC().Format("20060102T150405Z")
|
||||
uid := fmt.Sprintf("%s@example.com", userID)
|
||||
@@ -260,13 +295,14 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
// Fetch user's current data for name change detection and CardDAV update
|
||||
var email string
|
||||
var dob sql.NullTime
|
||||
var profilePicURL sql.NullString
|
||||
var currentFirstName, currentLastName string
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT email, date_of_birth, profile_pic_url FROM users WHERE id = $1
|
||||
`, userID).Scan(&email, &dob, &profilePicURL)
|
||||
SELECT email, date_of_birth, profile_pic_url, n_first_name, n_last_name FROM users WHERE id = $1
|
||||
`, userID).Scan(&email, &dob, &profilePicURL, ¤tFirstName, ¤tLastName)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch user %s: %v", userID, err)
|
||||
@@ -274,8 +310,30 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Use a transaction for atomicity: insert name history + update user
|
||||
tx, err := db.DB.Begin(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "failed to begin transaction", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
// If first or last name changed (via user edit), track the old names in history
|
||||
nameChanged := currentFirstName != req.FirstName || currentLastName != req.LastName
|
||||
if nameChanged {
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
INSERT INTO name_history (user_id, previous_first_name, previous_last_name)
|
||||
VALUES ($1, $2, $3)
|
||||
`, userID, currentFirstName, currentLastName)
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert name history for user %s: %v", userID, err)
|
||||
http.Error(w, "failed to record name change", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Update DB
|
||||
_, err = db.DB.Exec(r.Context(), `
|
||||
_, err = tx.Exec(r.Context(), `
|
||||
UPDATE users
|
||||
SET n_first_name = $1, n_last_name = $2, phone = $3, updated_at = NOW()
|
||||
WHERE id = $4
|
||||
@@ -286,6 +344,11 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(r.Context()); err != nil {
|
||||
http.Error(w, "failed to commit transaction", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update CardDAV (non-blocking)
|
||||
go func() {
|
||||
var dobStr string
|
||||
@@ -332,7 +395,7 @@ func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "user not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
@@ -352,6 +415,22 @@ func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
user.ReferralCodeUses = 0
|
||||
}
|
||||
|
||||
// Fetch unconsumed name history (booking_id IS NULL = not yet "seen" via a completed booking)
|
||||
var prevFirstName, prevLastName sql.NullString
|
||||
err = db.DB.QueryRow(r.Context(), `
|
||||
SELECT nh.previous_first_name, nh.previous_last_name
|
||||
FROM name_history nh
|
||||
WHERE nh.user_id = $1 AND nh.booking_id IS NULL
|
||||
ORDER BY nh.changed_at ASC
|
||||
LIMIT 1
|
||||
`, userID).Scan(&prevFirstName, &prevLastName)
|
||||
if err == nil && prevFirstName.Valid && prevLastName.Valid {
|
||||
if prevFirstName.String != user.FirstName || prevLastName.String != user.LastName {
|
||||
user.PreviousFirstName = &prevFirstName.String
|
||||
user.PreviousLastName = &prevLastName.String
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch social logins
|
||||
socialRows, err := db.DB.Query(r.Context(), `
|
||||
SELECT provider, created_at::text
|
||||
@@ -406,53 +485,67 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build query based on whether search is provided
|
||||
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 + "%"
|
||||
|
||||
listQuery = `
|
||||
SELECT u.id, u.fn, u.email, u.phone, u.account_role, u.created_at
|
||||
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
|
||||
`
|
||||
innerWithWhere := `SELECT * FROM (` + innerQuery + `
|
||||
WHERE (u.fn ILIKE $1 OR u.email ILIKE $1 OR u.phone ILIKE $1)
|
||||
) sub`
|
||||
|
||||
listArgs = []interface{}{searchPattern}
|
||||
|
||||
if cursorStr != "" {
|
||||
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
|
||||
cursorCount, cursorCreatedAt, cursorID, err := validators.ParseCursor3(cursorStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
listQuery += " HAVING (u.created_at, u.id) < ($2, $3)"
|
||||
listArgs = append(listArgs, cursorCreatedAt, cursorID)
|
||||
listQuery = innerWithWhere + ` WHERE (completed_count, created_at, id) < ($2, $3, $4)`
|
||||
listArgs = append(listArgs, cursorCount, cursorCreatedAt, cursorID)
|
||||
} else {
|
||||
listQuery = innerWithWhere
|
||||
}
|
||||
listQuery += " ORDER BY u.created_at DESC, u.id DESC LIMIT $" + strconv.Itoa(len(listArgs)+1)
|
||||
listQuery += ` ORDER BY completed_count DESC, created_at DESC, id DESC LIMIT $` + strconv.Itoa(len(listArgs)+1)
|
||||
listArgs = append(listArgs, perPage+1)
|
||||
} else {
|
||||
listQuery = `
|
||||
SELECT u.id, u.fn, u.email, u.phone, u.account_role, u.created_at
|
||||
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
|
||||
`
|
||||
innerNoWhere := `SELECT * FROM (` + innerQuery + `) sub`
|
||||
|
||||
if cursorStr != "" {
|
||||
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
|
||||
cursorCount, cursorCreatedAt, cursorID, err := validators.ParseCursor3(cursorStr)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
listQuery += " HAVING (u.created_at, u.id) < ($1, $2)"
|
||||
listArgs = append(listArgs, cursorCreatedAt, cursorID)
|
||||
listQuery = innerNoWhere + ` WHERE (completed_count, created_at, id) < ($1, $2, $3)`
|
||||
listArgs = append(listArgs, cursorCount, cursorCreatedAt, cursorID)
|
||||
} else {
|
||||
listQuery = innerNoWhere
|
||||
}
|
||||
listQuery += " ORDER BY u.created_at DESC, u.id DESC LIMIT $" + strconv.Itoa(len(listArgs)+1)
|
||||
listQuery += ` ORDER BY completed_count DESC, created_at DESC, id DESC LIMIT $` + strconv.Itoa(len(listArgs)+1)
|
||||
listArgs = append(listArgs, perPage+1)
|
||||
}
|
||||
|
||||
@@ -479,6 +572,8 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
for rows.Next() {
|
||||
var user UserListItem
|
||||
var prevFirstName, prevLastName sql.NullString
|
||||
var completedCount int
|
||||
err := rows.Scan(
|
||||
&user.ID,
|
||||
&user.FullName,
|
||||
@@ -486,12 +581,20 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
&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)
|
||||
}
|
||||
|
||||
@@ -504,7 +607,7 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if len(users) > perPage {
|
||||
users = users[:perPage]
|
||||
last := users[len(users)-1]
|
||||
cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID
|
||||
cursor := fmt.Sprintf("%d|%s|%s", last.CompletedCount, last.CreatedAt.Format(time.RFC3339Nano), last.ID)
|
||||
nextCursor = &cursor
|
||||
}
|
||||
|
||||
@@ -517,6 +620,7 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
|
||||
response := UserListResponse{
|
||||
Users: users,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
TotalPages: totalPages,
|
||||
NextCursor: nextCursor,
|
||||
@@ -576,7 +680,7 @@ func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "user not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
@@ -703,7 +807,7 @@ func AddPatchTestHandler(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "patch test not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -845,6 +949,15 @@ func UploadProfilePictureHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
ext := ".jpg"
|
||||
key := fmt.Sprintf("profiles/%s%s", userID, ext)
|
||||
bucket := getEnv("S3_PROFILE_PICS_BUCKET", "crussell-profile-pics")
|
||||
|
||||
var oldURL sql.NullString
|
||||
err = db.DB.QueryRow(r.Context(), `SELECT profile_pic_url FROM users WHERE id = $1`, userID).Scan(&oldURL)
|
||||
if err == nil && oldURL.Valid && oldURL.String != "" {
|
||||
if delErr := s3.Client.Delete(r.Context(), bucket, key); delErr != nil {
|
||||
log.Printf("Warning: Failed to delete old profile picture for user %s: %v", userID, delErr)
|
||||
}
|
||||
}
|
||||
|
||||
fileBytes, err = processProfileImage(fileBytes)
|
||||
if err != nil {
|
||||
@@ -853,8 +966,6 @@ func UploadProfilePictureHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bucket := getEnv("S3_PROFILE_PICS_BUCKET", "crussell-profile-pics")
|
||||
|
||||
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)
|
||||
@@ -932,7 +1043,7 @@ func GetNotificationPreferencesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
`, userID).Scan(&prefs.EmailEnabled, &prefs.SMSEnabled, &prefs.BrowserPushEnabled)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
prefs = NotificationPreferencesResponse{
|
||||
EmailEnabled: true,
|
||||
SMSEnabled: true,
|
||||
|
||||
Reference in New Issue
Block a user