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:
2026-06-20 16:58:29 +01:00
co-authored by Sisyphus
parent efd3ad5405
commit 8efb8d93bf
5 changed files with 466 additions and 166 deletions
+92 -27
View File
@@ -265,6 +265,8 @@ type UserSummary struct {
ReferralCodeUses *int `json:"referral_code_uses,omitempty"`
CreatedAt string `json:"created_at"`
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 {
@@ -661,6 +663,8 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
)
SELECT
b.id,
b.created_at,
b.user_id,
b.start_time,
b.status,
u.fn,
@@ -724,15 +728,15 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
return
}
if paramCount == 1 {
baseQuery += " WHERE (b.created_at, b.id) < ($1, $2)"
baseQuery += " WHERE (b.start_time, b.id) < ($1, $2)"
} 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)
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 {
baseQuery += fmt.Sprintf(" LIMIT $%d", paramCount)
args = append(args, req.PerPage+1)
@@ -789,9 +793,11 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
var userFullName string
var totalAmount, amountPaid, amountDue, preStartAmountPaid float64
var depositRequired bool
var createdAt time.Time
var bookingUserID string
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,
&depositRequired, new(int), &preStartAmountPaid,
); err != nil {
@@ -799,7 +805,8 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
b.User = &UserSummary{FullName: userFullName}
b.User = &UserSummary{ID: bookingUserID, FullName: userFullName}
b.CreatedAt = createdAt
b.TotalAmount = totalAmount
b.AmountPaid = amountPaid
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.
// The extra item is discarded; the cursor points to the last real item.
var nextCursor *string
if len(bookings) > req.PerPage {
bookings = bookings[:req.PerPage]
last := bookings[len(bookings)-1]
cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID
cursor := last.StartTime.Format(time.RFC3339Nano) + "|" + last.ID
nextCursor = &cursor
}
@@ -905,19 +951,18 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
args = append(args, userID)
paramCount := 2
// Cursor-based pagination: (created_at, id)
if cursorStr != "" {
cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr)
cursorStartTime, cursorID, err := validators.ParseCursor(cursorStr)
if err != nil {
http.Error(w, "Invalid cursor", http.StatusBadRequest)
return
}
baseQuery += fmt.Sprintf(" AND (b.created_at, b.id) < ($%d, $%d)", paramCount, paramCount+1)
args = append(args, cursorCreatedAt, cursorID)
baseQuery += fmt.Sprintf(" AND (b.start_time, b.id) < ($%d, $%d)", paramCount, paramCount+1)
args = append(args, cursorStartTime, cursorID)
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)
args = append(args, perPage+1)
@@ -1058,7 +1103,7 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
if len(bookings) > perPage {
bookings = bookings[:perPage]
last := bookings[len(bookings)-1]
cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID
cursor := last.StartTime.Format(time.RFC3339Nano) + "|" + last.ID
nextCursor = &cursor
}
@@ -1077,7 +1122,6 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
NextCursor: nextCursor,
}); err != nil {
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
}
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
@@ -1137,6 +1181,19 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
}
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(), `
SELECT service_id, name, price, duration_minutes FROM (
SELECT
@@ -1299,7 +1356,7 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
var startTime time.Time
var currentStatus string
if err := db.DB.QueryRow(r.Context(), "SELECT start_time, status FROM bookings WHERE id = $1", bookingID).Scan(&startTime, &currentStatus); err != nil {
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
@@ -1383,7 +1440,7 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
ORDER BY start_time ASC
LIMIT 1
`, 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)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
@@ -2324,7 +2381,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
var currentStatus string
if err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&currentStatus); 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)
return
}
@@ -2427,7 +2484,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
); 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)
return
}
@@ -2495,7 +2552,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
@@ -2573,7 +2630,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
)
RETURNING loyalty_stamps
`, 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)
}
}
@@ -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 {
@@ -2910,7 +2975,7 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
); 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)
return
}
@@ -3067,7 +3132,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
var originalStatus string
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 errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
return
}
@@ -3246,7 +3311,7 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &createdBy,
&depositRequired,
); 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)
return
}
@@ -3452,7 +3517,7 @@ func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) {
FROM bookings
WHERE id = $1 AND user_id = $2
`, bookingID, userID).Scan(&bookingIDDB, &userIDDB, &startTime, &status, &notes, &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)
return
}
@@ -3694,7 +3759,7 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
FROM bookings b
WHERE b.id = $1
`, 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)
return
}
@@ -4042,7 +4107,7 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
var bookingUserID string
var startTime time.Time
if err := db.DB.QueryRow(r.Context(), "SELECT status, user_id, start_time FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus, &bookingUserID, &startTime); err != nil {
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
@@ -4178,7 +4243,7 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
&booking.ID, &booking.User.ID, &booking.StartTime, &booking.Status,
&booking.Notes, &booking.CreatedAt, &booking.UpdatedAt, &booking.CreatedBy,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
+38 -17
View File
@@ -3,6 +3,7 @@ package bookings
import (
"context"
"crussell/db"
"github.com/jackc/pgx/v5"
"crussell/handlers/notifications"
"crussell/handlers/payments"
"crussell/handlers/scheduling"
@@ -47,7 +48,7 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
var originalStatus string
err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not cancellable", http.StatusNotFound)
return
}
@@ -187,7 +188,7 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
var bookingUserID string
err = tx.QueryRow(r.Context(), "SELECT status, user_id FROM bookings WHERE id = $1", bookingID).Scan(&originalStatus, &bookingUserID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not cancellable", http.StatusNotFound)
return
}
@@ -320,7 +321,7 @@ func AdminGetInProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
&fullName,
)
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)
return
}
@@ -369,7 +370,7 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
var currentStatus string
err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
@@ -1120,10 +1121,12 @@ type EditSnapshot struct {
}
type EditUserSummary struct {
ID string `json:"id"`
FullName string `json:"full_name"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
ID string `json:"id"`
FullName string `json:"full_name"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
PreviousFirstName *string `json:"previous_first_name,omitempty"`
PreviousLastName *string `json:"previous_last_name,omitempty"`
}
type EnrichedEditRequest struct {
@@ -1304,12 +1307,30 @@ func sumServiceDurations(services []EditServiceDetail) int {
// queryUserSummary fetches user details for the enriched edit request response.
func queryUserSummary(ctx context.Context, userID string) (*EditUserSummary, error) {
var summary EditUserSummary
var prevFirstName, prevLastName sql.NullString
err := db.DB.QueryRow(ctx, `
SELECT id, fn, email, phone FROM users WHERE id = $1
`, userID).Scan(&summary.ID, &summary.FullName, &summary.Email, &summary.Phone)
SELECT u.id, u.fn, u.email, u.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 {
return nil, err
}
if prevFirstName.Valid {
summary.PreviousFirstName = &prevFirstName.String
}
if prevLastName.Valid {
summary.PreviousLastName = &prevLastName.String
}
return &summary, nil
}
@@ -1331,7 +1352,7 @@ func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
var ownerID string
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
@@ -1440,7 +1461,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
var ownerID string
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
@@ -1877,7 +1898,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
WHERE id = $1
`, requestID).Scan(&bookingID, &newStartTime, &newServices, &notes, &hasOverrides)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Edit request not found", http.StatusNotFound)
return
}
@@ -2121,7 +2142,7 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
SELECT booking_id FROM booking_edit_requests WHERE id = $1
`, requestID).Scan(&bookingID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Edit request not found", http.StatusNotFound)
return
}
@@ -2198,7 +2219,7 @@ func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) {
var ownerID string
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
@@ -2229,7 +2250,7 @@ func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) {
&editReq.UpdatedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
@@ -2393,7 +2414,7 @@ func AdminGetBookingEditRequestHandler(w http.ResponseWriter, r *http.Request) {
&editReq.UpdatedAt,
)
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)
return
}
+163 -77
View File
@@ -3,6 +3,7 @@ package today
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
@@ -10,6 +11,7 @@ import (
"time"
"crussell/db"
"github.com/jackc/pgx/v5"
)
type ServiceInfo struct {
@@ -20,11 +22,13 @@ type ServiceInfo struct {
}
type UserInfo struct {
ID string `json:"id"`
FullName string `json:"full_name"`
Phone *string `json:"phone,omitempty"`
Email *string `json:"email,omitempty"`
ProfilePicURL *string `json:"profile_pic_url,omitempty"`
ID string `json:"id"`
FullName string `json:"full_name"`
Phone *string `json:"phone,omitempty"`
Email *string `json:"email,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 {
@@ -144,7 +148,7 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
LIMIT 1
`, todayStart, todayEnd)
if err != nil && err != sql.ErrNoRows {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
log.Printf("Error querying current appointment: %v", err)
}
@@ -177,7 +181,7 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
LIMIT 1
`, now, todayEnd)
if err != nil && err != sql.ErrNoRows {
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
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 {
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
} else {
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 {
ID string `json:"id"`
StartTime string `json:"start_time"`
Status string `json:"status"`
UserName string `json:"user_name"`
UserID string `json:"user_id"`
Services []string `json:"services"`
DurationMinutes int `json:"duration_minutes"`
ID string `json:"id"`
StartTime string `json:"start_time"`
Status string `json:"status"`
UserName string `json:"user_name"`
UserID string `json:"user_id"`
Services []string `json:"services"`
DurationMinutes int `json:"duration_minutes"`
PreviousFirstName *string `json:"previous_first_name,omitempty"`
PreviousLastName *string `json:"previous_last_name,omitempty"`
}
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{
@@ -904,13 +958,15 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
}
type PendingApproval struct {
ID string `json:"id"`
StartTime string `json:"start_time"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
Services []string `json:"services"`
DurationMinutes int `json:"duration_minutes"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
StartTime string `json:"start_time"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
PreviousFirstName *string `json:"previous_first_name,omitempty"`
PreviousLastName *string `json:"previous_last_name,omitempty"`
Services []string `json:"services"`
DurationMinutes int `json:"duration_minutes"`
CreatedAt string `json:"created_at"`
}
type PendingApprovalsResponse struct {
@@ -939,71 +995,101 @@ func GetPendingApprovalsHandler(w http.ResponseWriter, r *http.Request) {
}
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() {
var apt PendingApproval
var startTime time.Time
var createdAt time.Time
err := rows.Scan(
&apt.ID,
&startTime,
&createdAt,
&apt.UserID,
&apt.UserName,
)
if err != nil {
var a rawApproval
if err := rows.Scan(&a.id, &a.startTime, &a.createdAt, &a.userID, &a.userName); err != nil {
log.Printf("Failed to scan pending approval row: %v", err)
continue
}
apt.StartTime = startTime.Format(time.RFC3339)
apt.CreatedAt = createdAt.Format(time.RFC3339)
// Fetch services
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
raw = append(raw, a)
bookingIDs = append(bookingIDs, a.id)
if _, seen := seenUsers[a.userID]; !seen && a.userID != "" {
seenUsers[a.userID] = struct{}{}
userIDs = append(userIDs, a.userID)
}
}
var services []string
var totalDuration int
for serviceRows.Next() {
var name string
var duration int
if err := serviceRows.Scan(&name, &duration); err != nil {
log.Printf("Failed to scan service: %v", err)
continue
// Batch-fetch name_history for all users.
prevByUser := make(map[string][2]string)
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 {
for nhRows.Next() {
var uid, pfn, pln string
if err := nhRows.Scan(&uid, &pfn, &pln); err == nil {
prevByUser[uid] = [2]string{pfn, pln}
}
}
services = append(services, name)
totalDuration += duration
nhRows.Close()
}
serviceRows.Close()
}
apt.Services = services
apt.DurationMinutes = totalDuration
// Batch-fetch services for all bookings.
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)
}
+156 -45
View File
@@ -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, &currentFirstName, &currentLastName)
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,
+17
View File
@@ -5,6 +5,7 @@ import (
"github.com/go-playground/validator/v10"
"reflect"
"regexp"
"strconv"
"strings"
"time"
)
@@ -46,3 +47,19 @@ func ParseCursor(cursor string) (time.Time, string, error) {
}
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
}