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:
@@ -265,6 +265,8 @@ type UserSummary struct {
|
|||||||
ReferralCodeUses *int `json:"referral_code_uses,omitempty"`
|
ReferralCodeUses *int `json:"referral_code_uses,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
Notes *string `json:"notes,omitempty" validate:"omitempty,max=1000000"`
|
||||||
|
PreviousFirstName *string `json:"previous_first_name,omitempty"`
|
||||||
|
PreviousLastName *string `json:"previous_last_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BookingServiceDetail struct {
|
type BookingServiceDetail struct {
|
||||||
@@ -661,6 +663,8 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
b.id,
|
b.id,
|
||||||
|
b.created_at,
|
||||||
|
b.user_id,
|
||||||
b.start_time,
|
b.start_time,
|
||||||
b.status,
|
b.status,
|
||||||
u.fn,
|
u.fn,
|
||||||
@@ -724,15 +728,15 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if paramCount == 1 {
|
if paramCount == 1 {
|
||||||
baseQuery += " WHERE (b.created_at, b.id) < ($1, $2)"
|
baseQuery += " WHERE (b.start_time, b.id) < ($1, $2)"
|
||||||
} else {
|
} else {
|
||||||
baseQuery += fmt.Sprintf(" AND (b.created_at, b.id) < ($%d, $%d)", paramCount, paramCount+1)
|
baseQuery += fmt.Sprintf(" AND (b.start_time, b.id) < ($%d, $%d)", paramCount, paramCount+1)
|
||||||
}
|
}
|
||||||
args = append(args, cursorCreatedAt, cursorID)
|
args = append(args, cursorCreatedAt, cursorID)
|
||||||
paramCount += 2
|
paramCount += 2
|
||||||
}
|
}
|
||||||
|
|
||||||
baseQuery += " ORDER BY b.created_at DESC, b.id DESC"
|
baseQuery += " ORDER BY b.start_time DESC, b.id DESC"
|
||||||
if req.PerPage > 0 {
|
if req.PerPage > 0 {
|
||||||
baseQuery += fmt.Sprintf(" LIMIT $%d", paramCount)
|
baseQuery += fmt.Sprintf(" LIMIT $%d", paramCount)
|
||||||
args = append(args, req.PerPage+1)
|
args = append(args, req.PerPage+1)
|
||||||
@@ -789,9 +793,11 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var userFullName string
|
var userFullName string
|
||||||
var totalAmount, amountPaid, amountDue, preStartAmountPaid float64
|
var totalAmount, amountPaid, amountDue, preStartAmountPaid float64
|
||||||
var depositRequired bool
|
var depositRequired bool
|
||||||
|
var createdAt time.Time
|
||||||
|
var bookingUserID string
|
||||||
|
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&b.ID, &b.StartTime, &b.Status, &userFullName,
|
&b.ID, &createdAt, &bookingUserID, &b.StartTime, &b.Status, &userFullName,
|
||||||
&b.DurationMinutes, &totalAmount, &amountPaid, &amountDue,
|
&b.DurationMinutes, &totalAmount, &amountPaid, &amountDue,
|
||||||
&depositRequired, new(int), &preStartAmountPaid,
|
&depositRequired, new(int), &preStartAmountPaid,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -799,7 +805,8 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
b.User = &UserSummary{FullName: userFullName}
|
b.User = &UserSummary{ID: bookingUserID, FullName: userFullName}
|
||||||
|
b.CreatedAt = createdAt
|
||||||
b.TotalAmount = totalAmount
|
b.TotalAmount = totalAmount
|
||||||
b.AmountPaid = amountPaid
|
b.AmountPaid = amountPaid
|
||||||
b.AmountDue = amountDue
|
b.AmountDue = amountDue
|
||||||
@@ -849,13 +856,52 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
seenUserIDs := make(map[string]struct{})
|
||||||
|
var userIDs []string
|
||||||
|
for _, b := range bookings {
|
||||||
|
if b.User != nil && b.User.ID != "" {
|
||||||
|
if _, seen := seenUserIDs[b.User.ID]; !seen {
|
||||||
|
seenUserIDs[b.User.ID] = struct{}{}
|
||||||
|
userIDs = append(userIDs, b.User.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(userIDs) > 0 {
|
||||||
|
nhRows, err := db.DB.Query(r.Context(), `
|
||||||
|
SELECT DISTINCT ON (user_id) user_id, previous_first_name, previous_last_name
|
||||||
|
FROM name_history
|
||||||
|
WHERE user_id = ANY($1) AND booking_id IS NULL
|
||||||
|
ORDER BY user_id, changed_at ASC
|
||||||
|
`, userIDs)
|
||||||
|
if err == nil {
|
||||||
|
prevByUser := make(map[string][2]string)
|
||||||
|
for nhRows.Next() {
|
||||||
|
var uid, pfn, pln string
|
||||||
|
if err := nhRows.Scan(&uid, &pfn, &pln); err == nil {
|
||||||
|
prevByUser[uid] = [2]string{pfn, pln}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nhRows.Close()
|
||||||
|
for i := range bookings {
|
||||||
|
if bookings[i].User != nil {
|
||||||
|
if prev, ok := prevByUser[bookings[i].User.ID]; ok {
|
||||||
|
bookings[i].User.PreviousFirstName = &prev[0]
|
||||||
|
bookings[i].User.PreviousLastName = &prev[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// nextCursor is set only when we fetched perPage+1 items, proving a next page exists.
|
// nextCursor is set only when we fetched perPage+1 items, proving a next page exists.
|
||||||
// The extra item is discarded; the cursor points to the last real item.
|
// The extra item is discarded; the cursor points to the last real item.
|
||||||
var nextCursor *string
|
var nextCursor *string
|
||||||
if len(bookings) > req.PerPage {
|
if len(bookings) > req.PerPage {
|
||||||
bookings = bookings[:req.PerPage]
|
bookings = bookings[:req.PerPage]
|
||||||
last := bookings[len(bookings)-1]
|
last := bookings[len(bookings)-1]
|
||||||
cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID
|
cursor := last.StartTime.Format(time.RFC3339Nano) + "|" + last.ID
|
||||||
nextCursor = &cursor
|
nextCursor = &cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -905,19 +951,18 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
args = append(args, userID)
|
args = append(args, userID)
|
||||||
paramCount := 2
|
paramCount := 2
|
||||||
|
|
||||||
// Cursor-based pagination: (created_at, id)
|
|
||||||
if cursorStr != "" {
|
if cursorStr != "" {
|
||||||
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
|
cursorStartTime, cursorID, err := validators.ParseCursor(cursorStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid cursor", http.StatusBadRequest)
|
http.Error(w, "Invalid cursor", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
baseQuery += fmt.Sprintf(" AND (b.created_at, b.id) < ($%d, $%d)", paramCount, paramCount+1)
|
baseQuery += fmt.Sprintf(" AND (b.start_time, b.id) < ($%d, $%d)", paramCount, paramCount+1)
|
||||||
args = append(args, cursorCreatedAt, cursorID)
|
args = append(args, cursorStartTime, cursorID)
|
||||||
paramCount += 2
|
paramCount += 2
|
||||||
}
|
}
|
||||||
|
|
||||||
baseQuery += " ORDER BY b.created_at DESC, b.id DESC"
|
baseQuery += " ORDER BY b.start_time DESC, b.id DESC"
|
||||||
baseQuery += fmt.Sprintf(" LIMIT $%d", paramCount)
|
baseQuery += fmt.Sprintf(" LIMIT $%d", paramCount)
|
||||||
args = append(args, perPage+1)
|
args = append(args, perPage+1)
|
||||||
|
|
||||||
@@ -1058,7 +1103,7 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(bookings) > perPage {
|
if len(bookings) > perPage {
|
||||||
bookings = bookings[:perPage]
|
bookings = bookings[:perPage]
|
||||||
last := bookings[len(bookings)-1]
|
last := bookings[len(bookings)-1]
|
||||||
cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID
|
cursor := last.StartTime.Format(time.RFC3339Nano) + "|" + last.ID
|
||||||
nextCursor = &cursor
|
nextCursor = &cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1077,7 +1122,6 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
NextCursor: nextCursor,
|
NextCursor: nextCursor,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("Failed to encode bookings response: %v", err)
|
log.Printf("Failed to encode bookings response: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,7 +1164,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
booking.User.DateOfBirth = &s
|
booking.User.DateOfBirth = &s
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1137,6 +1181,19 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
booking.User.ReferralCodeUses = &referralCodeUses
|
booking.User.ReferralCodeUses = &referralCodeUses
|
||||||
|
|
||||||
|
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
|
||||||
|
`, booking.User.ID).Scan(&prevFirstName, &prevLastName)
|
||||||
|
if err == nil && prevFirstName.Valid && prevLastName.Valid {
|
||||||
|
booking.User.PreviousFirstName = &prevFirstName.String
|
||||||
|
booking.User.PreviousLastName = &prevLastName.String
|
||||||
|
}
|
||||||
|
|
||||||
serviceRows, err := db.DB.Query(r.Context(), `
|
serviceRows, err := db.DB.Query(r.Context(), `
|
||||||
SELECT service_id, name, price, duration_minutes FROM (
|
SELECT service_id, name, price, duration_minutes FROM (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -1299,7 +1356,7 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var startTime time.Time
|
var startTime time.Time
|
||||||
var currentStatus string
|
var currentStatus string
|
||||||
if err := db.DB.QueryRow(r.Context(), "SELECT start_time, status FROM bookings WHERE id = $1", bookingID).Scan(&startTime, ¤tStatus); err != nil {
|
if err := db.DB.QueryRow(r.Context(), "SELECT start_time, status FROM bookings WHERE id = $1", bookingID).Scan(&startTime, ¤tStatus); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1383,7 +1440,7 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
ORDER BY start_time ASC
|
ORDER BY start_time ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, startTime).Scan(&nextBookingStart)
|
`, startTime).Scan(&nextBookingStart)
|
||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
log.Printf("Failed to check next booking: %v", err)
|
log.Printf("Failed to check next booking: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -2324,7 +2381,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var currentStatus string
|
var currentStatus string
|
||||||
if err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(¤tStatus); err != nil {
|
if err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(¤tStatus); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
|
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2427,7 +2484,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
||||||
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
|
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2495,7 +2552,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
||||||
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2573,7 +2630,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
)
|
)
|
||||||
RETURNING loyalty_stamps
|
RETURNING loyalty_stamps
|
||||||
`, booking.User.ID, bookingID).Scan(&newStampCount); err != nil {
|
`, booking.User.ID, bookingID).Scan(&newStampCount); err != nil {
|
||||||
if !errors.Is(err, sql.ErrNoRows) {
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
|
log.Printf("Failed to add loyalty stamp for booking %s: %v", bookingID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2789,6 +2846,14 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Consume unconsumed name_history entries — this booking is the "first post-name-change
|
||||||
|
// booking" that completes. After this, we no longer show "formerly" on displays.
|
||||||
|
if _, err := tx.Exec(r.Context(), `
|
||||||
|
UPDATE name_history SET booking_id = $1
|
||||||
|
WHERE user_id = $2 AND booking_id IS NULL
|
||||||
|
`, bookingID, booking.User.ID); err != nil {
|
||||||
|
log.Printf("Failed to consume name_history for user %s: %v", booking.User.ID, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(r.Context()); err != nil {
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
@@ -2910,7 +2975,7 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
||||||
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found or already confirmed", http.StatusNotFound)
|
http.Error(w, "Booking not found or already confirmed", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3067,7 +3132,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var originalStatus string
|
var originalStatus string
|
||||||
var startTime time.Time
|
var startTime time.Time
|
||||||
if err := db.DB.QueryRow(r.Context(), "SELECT status, start_time FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus, &startTime); err != nil {
|
if err := db.DB.QueryRow(r.Context(), "SELECT status, start_time FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus, &startTime); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
|
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3246,7 +3311,7 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy,
|
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy,
|
||||||
&depositRequired,
|
&depositRequired,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
|
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3452,7 +3517,7 @@ func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
FROM bookings
|
FROM bookings
|
||||||
WHERE id = $1 AND user_id = $2
|
WHERE id = $1 AND user_id = $2
|
||||||
`, bookingID, userID).Scan(&bookingIDDB, &userIDDB, &startTime, &status, ¬es, &createdBy, &createdAt, &updatedAt, &durationMinutes); err != nil {
|
`, bookingID, userID).Scan(&bookingIDDB, &userIDDB, &startTime, &status, ¬es, &createdBy, &createdAt, &updatedAt, &durationMinutes); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3694,7 +3759,7 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
FROM bookings b
|
FROM bookings b
|
||||||
WHERE b.id = $1
|
WHERE b.id = $1
|
||||||
`, bookingID).Scan(&startTime, &durationMinutes); err != nil {
|
`, bookingID).Scan(&startTime, &durationMinutes); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -4042,7 +4107,7 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var bookingUserID string
|
var bookingUserID string
|
||||||
var startTime time.Time
|
var startTime time.Time
|
||||||
if err := db.DB.QueryRow(r.Context(), "SELECT status, user_id, start_time FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus, &bookingUserID, &startTime); err != nil {
|
if err := db.DB.QueryRow(r.Context(), "SELECT status, user_id, start_time FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus, &bookingUserID, &startTime); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -4178,7 +4243,7 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
|
||||||
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package bookings
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"crussell/handlers/notifications"
|
"crussell/handlers/notifications"
|
||||||
"crussell/handlers/payments"
|
"crussell/handlers/payments"
|
||||||
"crussell/handlers/scheduling"
|
"crussell/handlers/scheduling"
|
||||||
@@ -47,7 +48,7 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var originalStatus string
|
var originalStatus string
|
||||||
err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus)
|
err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not cancellable", http.StatusNotFound)
|
http.Error(w, "Booking not cancellable", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -187,7 +188,7 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var bookingUserID string
|
var bookingUserID string
|
||||||
err = tx.QueryRow(r.Context(), "SELECT status, user_id FROM bookings WHERE id = $1", bookingID).Scan(&originalStatus, &bookingUserID)
|
err = tx.QueryRow(r.Context(), "SELECT status, user_id FROM bookings WHERE id = $1", bookingID).Scan(&originalStatus, &bookingUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not cancellable", http.StatusNotFound)
|
http.Error(w, "Booking not cancellable", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -320,7 +321,7 @@ func AdminGetInProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
&fullName,
|
&fullName,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "No in-progress booking found", http.StatusNotFound)
|
http.Error(w, "No in-progress booking found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -369,7 +370,7 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var currentStatus string
|
var currentStatus string
|
||||||
err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus)
|
err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1120,10 +1121,12 @@ type EditSnapshot struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EditUserSummary struct {
|
type EditUserSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
Email *string `json:"email,omitempty"`
|
Email *string `json:"email,omitempty"`
|
||||||
Phone *string `json:"phone,omitempty"`
|
Phone *string `json:"phone,omitempty"`
|
||||||
|
PreviousFirstName *string `json:"previous_first_name,omitempty"`
|
||||||
|
PreviousLastName *string `json:"previous_last_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnrichedEditRequest struct {
|
type EnrichedEditRequest struct {
|
||||||
@@ -1304,12 +1307,30 @@ func sumServiceDurations(services []EditServiceDetail) int {
|
|||||||
// queryUserSummary fetches user details for the enriched edit request response.
|
// queryUserSummary fetches user details for the enriched edit request response.
|
||||||
func queryUserSummary(ctx context.Context, userID string) (*EditUserSummary, error) {
|
func queryUserSummary(ctx context.Context, userID string) (*EditUserSummary, error) {
|
||||||
var summary EditUserSummary
|
var summary EditUserSummary
|
||||||
|
var prevFirstName, prevLastName sql.NullString
|
||||||
err := db.DB.QueryRow(ctx, `
|
err := db.DB.QueryRow(ctx, `
|
||||||
SELECT id, fn, email, phone FROM users WHERE id = $1
|
SELECT u.id, u.fn, u.email, u.phone,
|
||||||
`, userID).Scan(&summary.ID, &summary.FullName, &summary.Email, &summary.Phone)
|
nh.previous_first_name, nh.previous_last_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT previous_first_name, previous_last_name
|
||||||
|
FROM name_history
|
||||||
|
WHERE user_id = u.id
|
||||||
|
ORDER BY changed_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) nh ON true
|
||||||
|
WHERE u.id = $1
|
||||||
|
`, userID).Scan(&summary.ID, &summary.FullName, &summary.Email, &summary.Phone,
|
||||||
|
&prevFirstName, &prevLastName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if prevFirstName.Valid {
|
||||||
|
summary.PreviousFirstName = &prevFirstName.String
|
||||||
|
}
|
||||||
|
if prevLastName.Valid {
|
||||||
|
summary.PreviousLastName = &prevLastName.String
|
||||||
|
}
|
||||||
return &summary, nil
|
return &summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1331,7 +1352,7 @@ func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var ownerID string
|
var ownerID string
|
||||||
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
|
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1440,7 +1461,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var ownerID string
|
var ownerID string
|
||||||
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
|
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1877,7 +1898,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, requestID).Scan(&bookingID, &newStartTime, &newServices, ¬es, &hasOverrides)
|
`, requestID).Scan(&bookingID, &newStartTime, &newServices, ¬es, &hasOverrides)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Edit request not found", http.StatusNotFound)
|
http.Error(w, "Edit request not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2121,7 +2142,7 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
SELECT booking_id FROM booking_edit_requests WHERE id = $1
|
SELECT booking_id FROM booking_edit_requests WHERE id = $1
|
||||||
`, requestID).Scan(&bookingID)
|
`, requestID).Scan(&bookingID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Edit request not found", http.StatusNotFound)
|
http.Error(w, "Edit request not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2198,7 +2219,7 @@ func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var ownerID string
|
var ownerID string
|
||||||
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
|
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "Booking not found", http.StatusNotFound)
|
http.Error(w, "Booking not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2229,7 +2250,7 @@ func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
&editReq.UpdatedAt,
|
&editReq.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
@@ -2393,7 +2414,7 @@ func AdminGetBookingEditRequestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
&editReq.UpdatedAt,
|
&editReq.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "No edit request pending for this booking", http.StatusNotFound)
|
http.Error(w, "No edit request pending for this booking", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+163
-77
@@ -3,6 +3,7 @@ package today
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServiceInfo struct {
|
type ServiceInfo struct {
|
||||||
@@ -20,11 +22,13 @@ type ServiceInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserInfo struct {
|
type UserInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
Phone *string `json:"phone,omitempty"`
|
Phone *string `json:"phone,omitempty"`
|
||||||
Email *string `json:"email,omitempty"`
|
Email *string `json:"email,omitempty"`
|
||||||
ProfilePicURL *string `json:"profile_pic_url,omitempty"`
|
ProfilePicURL *string `json:"profile_pic_url,omitempty"`
|
||||||
|
PreviousFirstName *string `json:"previous_first_name,omitempty"`
|
||||||
|
PreviousLastName *string `json:"previous_last_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AppointmentInfo struct {
|
type AppointmentInfo struct {
|
||||||
@@ -144,7 +148,7 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, todayStart, todayEnd)
|
`, todayStart, todayEnd)
|
||||||
|
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
log.Printf("Error querying current appointment: %v", err)
|
log.Printf("Error querying current appointment: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +181,7 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, now, todayEnd)
|
`, now, todayEnd)
|
||||||
|
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
log.Printf("Error querying next appointment: %v", err)
|
log.Printf("Error querying next appointment: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,6 +649,21 @@ func fetchAppointment(r *http.Request, query string, args ...interface{}) (*Appo
|
|||||||
if email.Valid {
|
if email.Valid {
|
||||||
user.Email = &email.String
|
user.Email = &email.String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch unconsumed name history for former name display
|
||||||
|
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 {
|
||||||
|
user.PreviousFirstName = &prevFirstName.String
|
||||||
|
user.PreviousLastName = &prevLastName.String
|
||||||
|
}
|
||||||
|
|
||||||
appointment.User = &user
|
appointment.User = &user
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Failed to fetch user %s: %v", userID, err)
|
log.Printf("Failed to fetch user %s: %v", userID, err)
|
||||||
@@ -713,13 +732,15 @@ func fetchAppointment(r *http.Request, query string, args ...interface{}) (*Appo
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TodayAppointment struct {
|
type TodayAppointment struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
StartTime string `json:"start_time"`
|
StartTime string `json:"start_time"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
UserName string `json:"user_name"`
|
UserName string `json:"user_name"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Services []string `json:"services"`
|
Services []string `json:"services"`
|
||||||
DurationMinutes int `json:"duration_minutes"`
|
DurationMinutes int `json:"duration_minutes"`
|
||||||
|
PreviousFirstName *string `json:"previous_first_name,omitempty"`
|
||||||
|
PreviousLastName *string `json:"previous_last_name,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TodayAppointmentsResponse struct {
|
type TodayAppointmentsResponse struct {
|
||||||
@@ -886,8 +907,41 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if appointments == nil {
|
{
|
||||||
appointments = []TodayAppointment{}
|
seenUserIDs := make(map[string]struct{})
|
||||||
|
var userIDs []string
|
||||||
|
for _, a := range appointments {
|
||||||
|
if a.UserID != "" {
|
||||||
|
if _, seen := seenUserIDs[a.UserID]; !seen {
|
||||||
|
seenUserIDs[a.UserID] = struct{}{}
|
||||||
|
userIDs = append(userIDs, a.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(userIDs) > 0 {
|
||||||
|
nhRows, err := db.DB.Query(r.Context(), `
|
||||||
|
SELECT DISTINCT ON (user_id) user_id, previous_first_name, previous_last_name
|
||||||
|
FROM name_history
|
||||||
|
WHERE user_id = ANY($1) AND booking_id IS NULL
|
||||||
|
ORDER BY user_id, changed_at ASC
|
||||||
|
`, userIDs)
|
||||||
|
if err == nil {
|
||||||
|
prevByUser := make(map[string][2]string)
|
||||||
|
for nhRows.Next() {
|
||||||
|
var uid, pfn, pln string
|
||||||
|
if err := nhRows.Scan(&uid, &pfn, &pln); err == nil {
|
||||||
|
prevByUser[uid] = [2]string{pfn, pln}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nhRows.Close()
|
||||||
|
for i := range appointments {
|
||||||
|
if prev, ok := prevByUser[appointments[i].UserID]; ok {
|
||||||
|
appointments[i].PreviousFirstName = &prev[0]
|
||||||
|
appointments[i].PreviousLastName = &prev[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response := TodayAppointmentsResponse{
|
response := TodayAppointmentsResponse{
|
||||||
@@ -904,13 +958,15 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PendingApproval struct {
|
type PendingApproval struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
StartTime string `json:"start_time"`
|
StartTime string `json:"start_time"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
UserName string `json:"user_name"`
|
UserName string `json:"user_name"`
|
||||||
Services []string `json:"services"`
|
PreviousFirstName *string `json:"previous_first_name,omitempty"`
|
||||||
DurationMinutes int `json:"duration_minutes"`
|
PreviousLastName *string `json:"previous_last_name,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
Services []string `json:"services"`
|
||||||
|
DurationMinutes int `json:"duration_minutes"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PendingApprovalsResponse struct {
|
type PendingApprovalsResponse struct {
|
||||||
@@ -939,71 +995,101 @@ func GetPendingApprovalsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var approvals []PendingApproval
|
type rawApproval struct {
|
||||||
|
id, userID, userName string
|
||||||
|
startTime, createdAt time.Time
|
||||||
|
}
|
||||||
|
var raw []rawApproval
|
||||||
|
var bookingIDs []string
|
||||||
|
var userIDs []string
|
||||||
|
seenUsers := make(map[string]struct{})
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var apt PendingApproval
|
var a rawApproval
|
||||||
var startTime time.Time
|
if err := rows.Scan(&a.id, &a.startTime, &a.createdAt, &a.userID, &a.userName); err != nil {
|
||||||
var createdAt time.Time
|
|
||||||
|
|
||||||
err := rows.Scan(
|
|
||||||
&apt.ID,
|
|
||||||
&startTime,
|
|
||||||
&createdAt,
|
|
||||||
&apt.UserID,
|
|
||||||
&apt.UserName,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to scan pending approval row: %v", err)
|
log.Printf("Failed to scan pending approval row: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
raw = append(raw, a)
|
||||||
apt.StartTime = startTime.Format(time.RFC3339)
|
bookingIDs = append(bookingIDs, a.id)
|
||||||
apt.CreatedAt = createdAt.Format(time.RFC3339)
|
if _, seen := seenUsers[a.userID]; !seen && a.userID != "" {
|
||||||
|
seenUsers[a.userID] = struct{}{}
|
||||||
// Fetch services
|
userIDs = append(userIDs, a.userID)
|
||||||
serviceRows, err := db.DB.Query(r.Context(), `
|
|
||||||
SELECT
|
|
||||||
s.name,
|
|
||||||
COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes
|
|
||||||
FROM booking_services bs
|
|
||||||
LEFT JOIN services s ON bs.service_id = s.id
|
|
||||||
WHERE bs.booking_id = $1
|
|
||||||
UNION ALL
|
|
||||||
SELECT
|
|
||||||
cs.name,
|
|
||||||
COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
|
|
||||||
FROM booking_custom_services bcs
|
|
||||||
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
|
||||||
WHERE bcs.booking_id = $1
|
|
||||||
ORDER BY name
|
|
||||||
`, apt.ID)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to fetch services for booking %s: %v", apt.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var services []string
|
// Batch-fetch name_history for all users.
|
||||||
var totalDuration int
|
prevByUser := make(map[string][2]string)
|
||||||
|
if len(userIDs) > 0 {
|
||||||
for serviceRows.Next() {
|
nhRows, err := db.DB.Query(r.Context(), `
|
||||||
var name string
|
SELECT DISTINCT ON (user_id) user_id, previous_first_name, previous_last_name
|
||||||
var duration int
|
FROM name_history
|
||||||
|
WHERE user_id = ANY($1) AND booking_id IS NULL
|
||||||
if err := serviceRows.Scan(&name, &duration); err != nil {
|
ORDER BY user_id, changed_at ASC
|
||||||
log.Printf("Failed to scan service: %v", err)
|
`, userIDs)
|
||||||
continue
|
if err == nil {
|
||||||
|
for nhRows.Next() {
|
||||||
|
var uid, pfn, pln string
|
||||||
|
if err := nhRows.Scan(&uid, &pfn, &pln); err == nil {
|
||||||
|
prevByUser[uid] = [2]string{pfn, pln}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
nhRows.Close()
|
||||||
services = append(services, name)
|
|
||||||
totalDuration += duration
|
|
||||||
}
|
}
|
||||||
serviceRows.Close()
|
}
|
||||||
|
|
||||||
apt.Services = services
|
// Batch-fetch services for all bookings.
|
||||||
apt.DurationMinutes = totalDuration
|
svcMap := make(map[string][]string)
|
||||||
|
durMap := make(map[string]int)
|
||||||
|
if len(bookingIDs) > 0 {
|
||||||
|
svcRows, err := db.DB.Query(r.Context(), `
|
||||||
|
SELECT booking_id, name, duration_minutes FROM (
|
||||||
|
SELECT bs.booking_id, s.name, COALESCE(bs.override_duration_minutes, s.duration_minutes) AS duration_minutes
|
||||||
|
FROM booking_services bs
|
||||||
|
LEFT JOIN services s ON bs.service_id = s.id
|
||||||
|
WHERE bs.booking_id = ANY($1)
|
||||||
|
UNION ALL
|
||||||
|
SELECT bcs.booking_id, cs.name, COALESCE(bcs.override_duration_minutes, cs.duration_minutes)
|
||||||
|
FROM booking_custom_services bcs
|
||||||
|
LEFT JOIN custom_services cs ON bcs.custom_service_id = cs.id
|
||||||
|
WHERE bcs.booking_id = ANY($1)
|
||||||
|
) sub ORDER BY name
|
||||||
|
`, bookingIDs)
|
||||||
|
if err == nil {
|
||||||
|
for svcRows.Next() {
|
||||||
|
var bid, name string
|
||||||
|
var dur int
|
||||||
|
if err := svcRows.Scan(&bid, &name, &dur); err == nil {
|
||||||
|
svcMap[bid] = append(svcMap[bid], name)
|
||||||
|
durMap[bid] += dur
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svcRows.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
approvals := make([]PendingApproval, 0, len(raw))
|
||||||
|
for _, a := range raw {
|
||||||
|
prevFirstName, prevLastName := "", ""
|
||||||
|
if p, ok := prevByUser[a.userID]; ok {
|
||||||
|
prevFirstName = p[0]
|
||||||
|
prevLastName = p[1]
|
||||||
|
}
|
||||||
|
apt := PendingApproval{
|
||||||
|
ID: a.id,
|
||||||
|
StartTime: a.startTime.Format(time.RFC3339),
|
||||||
|
CreatedAt: a.createdAt.Format(time.RFC3339),
|
||||||
|
UserID: a.userID,
|
||||||
|
UserName: a.userName,
|
||||||
|
Services: svcMap[a.id],
|
||||||
|
DurationMinutes: durMap[a.id],
|
||||||
|
}
|
||||||
|
if prevFirstName != "" {
|
||||||
|
apt.PreviousFirstName = &prevFirstName
|
||||||
|
}
|
||||||
|
if prevLastName != "" {
|
||||||
|
apt.PreviousLastName = &prevLastName
|
||||||
|
}
|
||||||
approvals = append(approvals, apt)
|
approvals = append(approvals, apt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"golang.org/x/text/language"
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
"crussell/db"
|
"crussell/db"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"crussell/handlers/auth"
|
"crussell/handlers/auth"
|
||||||
"crussell/internal/images"
|
"crussell/internal/images"
|
||||||
"crussell/internal/s3"
|
"crussell/internal/s3"
|
||||||
@@ -49,8 +50,11 @@ type UserProfile struct {
|
|||||||
LoyaltyStamps int `json:"loyaltyStamps"`
|
LoyaltyStamps int `json:"loyaltyStamps"`
|
||||||
ReferralCode string `json:"referralCode"`
|
ReferralCode string `json:"referralCode"`
|
||||||
ReferralCodeUses int `json:"referralCodeUses"`
|
ReferralCodeUses int `json:"referralCodeUses"`
|
||||||
|
ReferralSavings float64 `json:"referralSavings"`
|
||||||
ProfilePicURL *string `json:"profilePicUrl,omitempty"`
|
ProfilePicURL *string `json:"profilePicUrl,omitempty"`
|
||||||
DepositsRequired int `json:"deposits_required"`
|
DepositsRequired int `json:"deposits_required"`
|
||||||
|
PreviousFirstName *string `json:"previousFirstName,omitempty"`
|
||||||
|
PreviousLastName *string `json:"previousLastName,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProfileRequest struct {
|
type UpdateProfileRequest struct {
|
||||||
@@ -86,6 +90,10 @@ type AdminUserDetail struct {
|
|||||||
|
|
||||||
// Social logins
|
// Social logins
|
||||||
SocialLogins []SocialLogin `json:"socialLogins,omitempty"`
|
SocialLogins []SocialLogin `json:"socialLogins,omitempty"`
|
||||||
|
|
||||||
|
// Name change history
|
||||||
|
PreviousFirstName *string `json:"previousFirstName,omitempty"`
|
||||||
|
PreviousLastName *string `json:"previousLastName,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SocialLogin struct {
|
type SocialLogin struct {
|
||||||
@@ -94,12 +102,15 @@ type SocialLogin struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserListItem struct {
|
type UserListItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
FullName string `json:"fullName"`
|
FullName string `json:"fullName"`
|
||||||
Email *string `json:"email,omitempty"`
|
Email *string `json:"email,omitempty"`
|
||||||
Phone *string `json:"phone,omitempty"`
|
Phone *string `json:"phone,omitempty"`
|
||||||
AccountRole string `json:"account_role"`
|
AccountRole string `json:"account_role"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
PreviousFirstName *string `json:"previousFirstName,omitempty"`
|
||||||
|
PreviousLastName *string `json:"previousLastName,omitempty"`
|
||||||
|
CompletedCount int `json:"completed_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserListResponse struct {
|
type UserListResponse struct {
|
||||||
@@ -125,13 +136,14 @@ func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
id, email, n_first_name, n_last_name, phone,
|
id, email, n_first_name, n_last_name, phone,
|
||||||
date_of_birth::text, account_role, loyalty_stamps,
|
date_of_birth::text, account_role, loyalty_stamps,
|
||||||
referral_code, profile_pic_url, deposits_required,
|
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
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, userID).Scan(
|
`, userID).Scan(
|
||||||
&user.ID, &user.Email, &user.FirstName, &user.LastName,
|
&user.ID, &user.Email, &user.FirstName, &user.LastName,
|
||||||
&user.Phone, &user.DateOfBirth, &user.Role,
|
&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 {
|
if err != nil {
|
||||||
@@ -139,15 +151,38 @@ func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(user)
|
json.NewEncoder(w).Encode(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/user/profile
|
// 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 {
|
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)
|
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")
|
timestamp := time.Now().UTC().Format("20060102T150405Z")
|
||||||
uid := fmt.Sprintf("%s@example.com", userID)
|
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.FirstName = titleCaser.String(strings.ToLower(req.FirstName))
|
||||||
req.LastName = titleCaser.String(strings.ToLower(req.LastName))
|
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 email string
|
||||||
var dob sql.NullTime
|
var dob sql.NullTime
|
||||||
var profilePicURL sql.NullString
|
var profilePicURL sql.NullString
|
||||||
|
var currentFirstName, currentLastName string
|
||||||
err = db.DB.QueryRow(r.Context(), `
|
err = db.DB.QueryRow(r.Context(), `
|
||||||
SELECT email, date_of_birth, profile_pic_url FROM users WHERE id = $1
|
SELECT email, date_of_birth, profile_pic_url, n_first_name, n_last_name FROM users WHERE id = $1
|
||||||
`, userID).Scan(&email, &dob, &profilePicURL)
|
`, userID).Scan(&email, &dob, &profilePicURL, ¤tFirstName, ¤tLastName)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to fetch user %s: %v", userID, err)
|
log.Printf("Failed to fetch user %s: %v", userID, err)
|
||||||
@@ -274,8 +310,30 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// Update DB
|
||||||
_, err = db.DB.Exec(r.Context(), `
|
_, err = tx.Exec(r.Context(), `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET n_first_name = $1, n_last_name = $2, phone = $3, updated_at = NOW()
|
SET n_first_name = $1, n_last_name = $2, phone = $3, updated_at = NOW()
|
||||||
WHERE id = $4
|
WHERE id = $4
|
||||||
@@ -286,6 +344,11 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
http.Error(w, "failed to commit transaction", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Update CardDAV (non-blocking)
|
// Update CardDAV (non-blocking)
|
||||||
go func() {
|
go func() {
|
||||||
var dobStr string
|
var dobStr string
|
||||||
@@ -332,7 +395,7 @@ func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "user not found", http.StatusNotFound)
|
http.Error(w, "user not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -352,6 +415,22 @@ func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
user.ReferralCodeUses = 0
|
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
|
// Fetch social logins
|
||||||
socialRows, err := db.DB.Query(r.Context(), `
|
socialRows, err := db.DB.Query(r.Context(), `
|
||||||
SELECT provider, created_at::text
|
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 listQuery string
|
||||||
var listArgs []interface{}
|
var listArgs []interface{}
|
||||||
|
|
||||||
if searchTerm != "" {
|
if searchTerm != "" {
|
||||||
searchPattern := "%" + searchTerm + "%"
|
searchPattern := "%" + searchTerm + "%"
|
||||||
|
|
||||||
listQuery = `
|
innerWithWhere := `SELECT * FROM (` + innerQuery + `
|
||||||
SELECT u.id, u.fn, u.email, u.phone, u.account_role, u.created_at
|
WHERE (u.fn ILIKE $1 OR u.email ILIKE $1 OR u.phone ILIKE $1)
|
||||||
FROM users u
|
) sub`
|
||||||
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
|
|
||||||
`
|
|
||||||
listArgs = []interface{}{searchPattern}
|
listArgs = []interface{}{searchPattern}
|
||||||
|
|
||||||
if cursorStr != "" {
|
if cursorStr != "" {
|
||||||
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
|
cursorCount, cursorCreatedAt, cursorID, err := validators.ParseCursor3(cursorStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
listQuery += " HAVING (u.created_at, u.id) < ($2, $3)"
|
listQuery = innerWithWhere + ` WHERE (completed_count, created_at, id) < ($2, $3, $4)`
|
||||||
listArgs = append(listArgs, cursorCreatedAt, cursorID)
|
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)
|
listArgs = append(listArgs, perPage+1)
|
||||||
} else {
|
} else {
|
||||||
listQuery = `
|
innerNoWhere := `SELECT * FROM (` + innerQuery + `) sub`
|
||||||
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
|
|
||||||
`
|
|
||||||
|
|
||||||
if cursorStr != "" {
|
if cursorStr != "" {
|
||||||
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
|
cursorCount, cursorCreatedAt, cursorID, err := validators.ParseCursor3(cursorStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
|
http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
listQuery += " HAVING (u.created_at, u.id) < ($1, $2)"
|
listQuery = innerNoWhere + ` WHERE (completed_count, created_at, id) < ($1, $2, $3)`
|
||||||
listArgs = append(listArgs, cursorCreatedAt, cursorID)
|
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)
|
listArgs = append(listArgs, perPage+1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +572,8 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var user UserListItem
|
var user UserListItem
|
||||||
|
var prevFirstName, prevLastName sql.NullString
|
||||||
|
var completedCount int
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&user.ID,
|
&user.ID,
|
||||||
&user.FullName,
|
&user.FullName,
|
||||||
@@ -486,12 +581,20 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
&user.Phone,
|
&user.Phone,
|
||||||
&user.AccountRole,
|
&user.AccountRole,
|
||||||
&user.CreatedAt,
|
&user.CreatedAt,
|
||||||
|
&prevFirstName,
|
||||||
|
&prevLastName,
|
||||||
|
&completedCount,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to scan user row: %v", err)
|
log.Printf("Failed to scan user row: %v", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if prevFirstName.Valid && prevLastName.Valid {
|
||||||
|
user.PreviousFirstName = &prevFirstName.String
|
||||||
|
user.PreviousLastName = &prevLastName.String
|
||||||
|
}
|
||||||
|
user.CompletedCount = completedCount
|
||||||
users = append(users, user)
|
users = append(users, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,7 +607,7 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
if len(users) > perPage {
|
if len(users) > perPage {
|
||||||
users = users[:perPage]
|
users = users[:perPage]
|
||||||
last := users[len(users)-1]
|
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
|
nextCursor = &cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,6 +620,7 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
response := UserListResponse{
|
response := UserListResponse{
|
||||||
Users: users,
|
Users: users,
|
||||||
Total: total,
|
Total: total,
|
||||||
|
Page: page,
|
||||||
PerPage: perPage,
|
PerPage: perPage,
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
NextCursor: nextCursor,
|
NextCursor: nextCursor,
|
||||||
@@ -576,7 +680,7 @@ func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var passwordHash string
|
var passwordHash string
|
||||||
err := db.DB.QueryRow(r.Context(), `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&passwordHash)
|
err := db.DB.QueryRow(r.Context(), `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&passwordHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "user not found", http.StatusNotFound)
|
http.Error(w, "user not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -703,7 +807,7 @@ func AddPatchTestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
var patchTestID string
|
var patchTestID string
|
||||||
err := db.DB.QueryRow(r.Context(), `SELECT id FROM patch_tests WHERE id = $1`, req.PatchTestID).Scan(&patchTestID)
|
err := db.DB.QueryRow(r.Context(), `SELECT id FROM patch_tests WHERE id = $1`, req.PatchTestID).Scan(&patchTestID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
http.Error(w, "patch test not found", http.StatusBadRequest)
|
http.Error(w, "patch test not found", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -845,6 +949,15 @@ func UploadProfilePictureHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
ext := ".jpg"
|
ext := ".jpg"
|
||||||
key := fmt.Sprintf("profiles/%s%s", userID, ext)
|
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)
|
fileBytes, err = processProfileImage(fileBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -853,8 +966,6 @@ func UploadProfilePictureHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
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)
|
log.Printf("Failed to upload profile picture to S3: %v", err)
|
||||||
http.Error(w, "Failed to upload image", http.StatusInternalServerError)
|
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)
|
`, userID).Scan(&prefs.EmailEnabled, &prefs.SMSEnabled, &prefs.BrowserPushEnabled)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
prefs = NotificationPreferencesResponse{
|
prefs = NotificationPreferencesResponse{
|
||||||
EmailEnabled: true,
|
EmailEnabled: true,
|
||||||
SMSEnabled: true,
|
SMSEnabled: true,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -46,3 +47,19 @@ func ParseCursor(cursor string) (time.Time, string, error) {
|
|||||||
}
|
}
|
||||||
return t, parts[1], nil
|
return t, parts[1], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ParseCursor3(cursor string) (int, time.Time, string, error) {
|
||||||
|
parts := strings.SplitN(cursor, "|", 3)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return 0, time.Time{}, "", fmt.Errorf("invalid cursor format")
|
||||||
|
}
|
||||||
|
count, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, time.Time{}, "", fmt.Errorf("invalid cursor completed_count: %w", err)
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339, parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, time.Time{}, "", fmt.Errorf("invalid cursor created_at: %w", err)
|
||||||
|
}
|
||||||
|
return count, t, parts[2], nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user