diff --git a/backend/handlers/bookings/bookings.go b/backend/handlers/bookings/bookings.go index 94afab3..bf1cbeb 100644 --- a/backend/handlers/bookings/bookings.go +++ b/backend/handlers/bookings/bookings.go @@ -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, ¤tStatus); 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(¤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) 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, ¬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) 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(¤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) 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 } diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index fa8a215..ad8d6ac 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -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(¤tStatus) 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, ¬es, &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 } diff --git a/backend/handlers/today/today.go b/backend/handlers/today/today.go index bd6d542..fb06e43 100644 --- a/backend/handlers/today/today.go +++ b/backend/handlers/today/today.go @@ -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) } diff --git a/backend/handlers/user/profile.go b/backend/handlers/user/profile.go index 2512908..e2772ea 100644 --- a/backend/handlers/user/profile.go +++ b/backend/handlers/user/profile.go @@ -22,6 +22,7 @@ import ( "golang.org/x/text/language" "crussell/db" + "github.com/jackc/pgx/v5" "crussell/handlers/auth" "crussell/internal/images" "crussell/internal/s3" @@ -49,8 +50,11 @@ type UserProfile struct { LoyaltyStamps int `json:"loyaltyStamps"` ReferralCode string `json:"referralCode"` ReferralCodeUses int `json:"referralCodeUses"` + ReferralSavings float64 `json:"referralSavings"` ProfilePicURL *string `json:"profilePicUrl,omitempty"` DepositsRequired int `json:"deposits_required"` + PreviousFirstName *string `json:"previousFirstName,omitempty"` + PreviousLastName *string `json:"previousLastName,omitempty"` } type UpdateProfileRequest struct { @@ -86,6 +90,10 @@ type AdminUserDetail struct { // Social logins SocialLogins []SocialLogin `json:"socialLogins,omitempty"` + + // Name change history + PreviousFirstName *string `json:"previousFirstName,omitempty"` + PreviousLastName *string `json:"previousLastName,omitempty"` } type SocialLogin struct { @@ -94,12 +102,15 @@ type SocialLogin struct { } type UserListItem struct { - ID string `json:"id"` - FullName string `json:"fullName"` - Email *string `json:"email,omitempty"` - Phone *string `json:"phone,omitempty"` - AccountRole string `json:"account_role"` - CreatedAt time.Time `json:"created_at"` + ID string `json:"id"` + FullName string `json:"fullName"` + Email *string `json:"email,omitempty"` + Phone *string `json:"phone,omitempty"` + AccountRole string `json:"account_role"` + CreatedAt time.Time `json:"created_at"` + PreviousFirstName *string `json:"previousFirstName,omitempty"` + PreviousLastName *string `json:"previousLastName,omitempty"` + CompletedCount int `json:"completed_count"` } type UserListResponse struct { @@ -125,13 +136,14 @@ func GetProfileHandler(w http.ResponseWriter, r *http.Request) { id, email, n_first_name, n_last_name, phone, date_of_birth::text, account_role, loyalty_stamps, referral_code, profile_pic_url, deposits_required, - (SELECT COUNT(*) FROM user_referrals WHERE referrer_id = users.id AND claimed_booking_id IS NOT NULL) AS referral_code_uses + (SELECT COUNT(*) FROM user_referrals WHERE referrer_id = users.id AND claimed_booking_id IS NOT NULL) AS referral_code_uses, + (SELECT COALESCE(SUM(bd.discount_amount), 0) FROM booking_discounts bd WHERE bd.user_id = users.id AND bd.discount_source = 'referral') AS referral_savings FROM users WHERE id = $1 `, userID).Scan( &user.ID, &user.Email, &user.FirstName, &user.LastName, &user.Phone, &user.DateOfBirth, &user.Role, - &user.LoyaltyStamps, &user.ReferralCode, &user.ProfilePicURL, &user.DepositsRequired, &user.ReferralCodeUses, + &user.LoyaltyStamps, &user.ReferralCode, &user.ProfilePicURL, &user.DepositsRequired, &user.ReferralCodeUses, &user.ReferralSavings, ) if err != nil { @@ -139,15 +151,38 @@ func GetProfileHandler(w http.ResponseWriter, r *http.Request) { return } + // Check if user has an unconsumed previous name (booking_id IS NULL means the name + // change hasn't been "seen" via a completed booking yet). + var prevFirstName, prevLastName sql.NullString + err = db.DB.QueryRow(r.Context(), ` + SELECT previous_first_name, previous_last_name + FROM name_history + WHERE user_id = $1 AND booking_id IS NULL + ORDER BY changed_at ASC + LIMIT 1 + `, userID).Scan(&prevFirstName, &prevLastName) + if err == nil && prevFirstName.Valid && prevLastName.Valid { + if prevFirstName.String != user.FirstName || prevLastName.String != user.LastName { + user.PreviousFirstName = &prevFirstName.String + user.PreviousLastName = &prevLastName.String + } + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) } // PUT /api/user/profile -// updateCardDAV updates an existing contact in SabreDAV using user ID +// updateCardDAV updates an existing contact in SabreDAV using user ID. +// The DAV base URL is configured via DAV_BASE_URL — if unset, the update is skipped +// silently (allowing the handler to work in dev environments without a DAV server). func updateCardDAV(userID, firstName, lastName, email, phone, dob, profilePicURL string) error { + davBase := os.Getenv("DAV_BASE_URL") + if davBase == "" { + return nil + } filename := fmt.Sprintf("%s.vcf", userID) - url := fmt.Sprintf("http://nginx/dav/addressbooks/principals/default/default/%s", filename) + url := fmt.Sprintf("%s/addressbooks/principals/default/default/%s", davBase, filename) timestamp := time.Now().UTC().Format("20060102T150405Z") uid := fmt.Sprintf("%s@example.com", userID) @@ -260,13 +295,14 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) { req.FirstName = titleCaser.String(strings.ToLower(req.FirstName)) req.LastName = titleCaser.String(strings.ToLower(req.LastName)) - // Fetch user's email and DOB for CardDAV update + // Fetch user's current data for name change detection and CardDAV update var email string var dob sql.NullTime var profilePicURL sql.NullString + var currentFirstName, currentLastName string err = db.DB.QueryRow(r.Context(), ` - SELECT email, date_of_birth, profile_pic_url FROM users WHERE id = $1 - `, userID).Scan(&email, &dob, &profilePicURL) + SELECT email, date_of_birth, profile_pic_url, n_first_name, n_last_name FROM users WHERE id = $1 + `, userID).Scan(&email, &dob, &profilePicURL, ¤tFirstName, ¤tLastName) if err != nil { log.Printf("Failed to fetch user %s: %v", userID, err) @@ -274,8 +310,30 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) { return } + // Use a transaction for atomicity: insert name history + update user + tx, err := db.DB.Begin(r.Context()) + if err != nil { + http.Error(w, "failed to begin transaction", http.StatusInternalServerError) + return + } + defer tx.Rollback(r.Context()) + + // If first or last name changed (via user edit), track the old names in history + nameChanged := currentFirstName != req.FirstName || currentLastName != req.LastName + if nameChanged { + _, err = tx.Exec(r.Context(), ` + INSERT INTO name_history (user_id, previous_first_name, previous_last_name) + VALUES ($1, $2, $3) + `, userID, currentFirstName, currentLastName) + if err != nil { + log.Printf("Failed to insert name history for user %s: %v", userID, err) + http.Error(w, "failed to record name change", http.StatusInternalServerError) + return + } + } + // Update DB - _, err = db.DB.Exec(r.Context(), ` + _, err = tx.Exec(r.Context(), ` UPDATE users SET n_first_name = $1, n_last_name = $2, phone = $3, updated_at = NOW() WHERE id = $4 @@ -286,6 +344,11 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) { return } + if err := tx.Commit(r.Context()); err != nil { + http.Error(w, "failed to commit transaction", http.StatusInternalServerError) + return + } + // Update CardDAV (non-blocking) go func() { var dobStr string @@ -332,7 +395,7 @@ func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) { ) if err != nil { - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "user not found", http.StatusNotFound) return } @@ -352,6 +415,22 @@ func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) { user.ReferralCodeUses = 0 } + // Fetch unconsumed name history (booking_id IS NULL = not yet "seen" via a completed booking) + var prevFirstName, prevLastName sql.NullString + err = db.DB.QueryRow(r.Context(), ` + SELECT nh.previous_first_name, nh.previous_last_name + FROM name_history nh + WHERE nh.user_id = $1 AND nh.booking_id IS NULL + ORDER BY nh.changed_at ASC + LIMIT 1 + `, userID).Scan(&prevFirstName, &prevLastName) + if err == nil && prevFirstName.Valid && prevLastName.Valid { + if prevFirstName.String != user.FirstName || prevLastName.String != user.LastName { + user.PreviousFirstName = &prevFirstName.String + user.PreviousLastName = &prevLastName.String + } + } + // Fetch social logins socialRows, err := db.DB.Query(r.Context(), ` SELECT provider, created_at::text @@ -406,53 +485,67 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) { } } - // Build query based on whether search is provided + page := 1 + if pageStr := query.Get("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + innerQuery := ` + SELECT u.id, u.fn, u.email, u.phone, u.account_role, u.created_at, + nh.previous_first_name, nh.previous_last_name, + (SELECT COUNT(*) FROM bookings b WHERE b.user_id = u.id AND b.status = 'completed') AS completed_count + FROM users u + LEFT JOIN LATERAL ( + SELECT previous_first_name, previous_last_name + FROM name_history + WHERE user_id = u.id AND booking_id IS NULL + ORDER BY changed_at ASC + LIMIT 1 + ) nh ON true + ` + var listQuery string var listArgs []interface{} if searchTerm != "" { searchPattern := "%" + searchTerm + "%" - listQuery = ` - SELECT u.id, u.fn, u.email, u.phone, u.account_role, u.created_at - FROM users u - LEFT JOIN bookings b ON u.id = b.user_id - WHERE (u.fn ILIKE $1 - OR u.email ILIKE $1 - OR u.phone ILIKE $1) - GROUP BY u.id, u.fn, u.email, u.phone, u.account_role, u.created_at - ` + innerWithWhere := `SELECT * FROM (` + innerQuery + ` + WHERE (u.fn ILIKE $1 OR u.email ILIKE $1 OR u.phone ILIKE $1) + ) sub` + listArgs = []interface{}{searchPattern} if cursorStr != "" { - cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) + cursorCount, cursorCreatedAt, cursorID, err := validators.ParseCursor3(cursorStr) if err != nil { http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest) return } - listQuery += " HAVING (u.created_at, u.id) < ($2, $3)" - listArgs = append(listArgs, cursorCreatedAt, cursorID) + listQuery = innerWithWhere + ` WHERE (completed_count, created_at, id) < ($2, $3, $4)` + listArgs = append(listArgs, cursorCount, cursorCreatedAt, cursorID) + } else { + listQuery = innerWithWhere } - listQuery += " ORDER BY u.created_at DESC, u.id DESC LIMIT $" + strconv.Itoa(len(listArgs)+1) + listQuery += ` ORDER BY completed_count DESC, created_at DESC, id DESC LIMIT $` + strconv.Itoa(len(listArgs)+1) listArgs = append(listArgs, perPage+1) } else { - listQuery = ` - SELECT u.id, u.fn, u.email, u.phone, u.account_role, u.created_at - FROM users u - LEFT JOIN bookings b ON u.id = b.user_id - GROUP BY u.id, u.fn, u.email, u.phone, u.account_role, u.created_at - ` + innerNoWhere := `SELECT * FROM (` + innerQuery + `) sub` if cursorStr != "" { - cursorCreatedAt, cursorID, err := validators.ParseCursor(cursorStr) + cursorCount, cursorCreatedAt, cursorID, err := validators.ParseCursor3(cursorStr) if err != nil { http.Error(w, "Invalid cursor: "+err.Error(), http.StatusBadRequest) return } - listQuery += " HAVING (u.created_at, u.id) < ($1, $2)" - listArgs = append(listArgs, cursorCreatedAt, cursorID) + listQuery = innerNoWhere + ` WHERE (completed_count, created_at, id) < ($1, $2, $3)` + listArgs = append(listArgs, cursorCount, cursorCreatedAt, cursorID) + } else { + listQuery = innerNoWhere } - listQuery += " ORDER BY u.created_at DESC, u.id DESC LIMIT $" + strconv.Itoa(len(listArgs)+1) + listQuery += ` ORDER BY completed_count DESC, created_at DESC, id DESC LIMIT $` + strconv.Itoa(len(listArgs)+1) listArgs = append(listArgs, perPage+1) } @@ -479,6 +572,8 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) { for rows.Next() { var user UserListItem + var prevFirstName, prevLastName sql.NullString + var completedCount int err := rows.Scan( &user.ID, &user.FullName, @@ -486,12 +581,20 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) { &user.Phone, &user.AccountRole, &user.CreatedAt, + &prevFirstName, + &prevLastName, + &completedCount, ) if err != nil { log.Printf("Failed to scan user row: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } + if prevFirstName.Valid && prevLastName.Valid { + user.PreviousFirstName = &prevFirstName.String + user.PreviousLastName = &prevLastName.String + } + user.CompletedCount = completedCount users = append(users, user) } @@ -504,7 +607,7 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) { if len(users) > perPage { users = users[:perPage] last := users[len(users)-1] - cursor := last.CreatedAt.Format(time.RFC3339) + "|" + last.ID + cursor := fmt.Sprintf("%d|%s|%s", last.CompletedCount, last.CreatedAt.Format(time.RFC3339Nano), last.ID) nextCursor = &cursor } @@ -517,6 +620,7 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) { response := UserListResponse{ Users: users, Total: total, + Page: page, PerPage: perPage, TotalPages: totalPages, NextCursor: nextCursor, @@ -576,7 +680,7 @@ func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) { var passwordHash string err := db.DB.QueryRow(r.Context(), `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&passwordHash) if err != nil { - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "user not found", http.StatusNotFound) return } @@ -703,7 +807,7 @@ func AddPatchTestHandler(w http.ResponseWriter, r *http.Request) { var patchTestID string err := db.DB.QueryRow(r.Context(), `SELECT id FROM patch_tests WHERE id = $1`, req.PatchTestID).Scan(&patchTestID) if err != nil { - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "patch test not found", http.StatusBadRequest) return } @@ -845,6 +949,15 @@ func UploadProfilePictureHandler(w http.ResponseWriter, r *http.Request) { ext := ".jpg" key := fmt.Sprintf("profiles/%s%s", userID, ext) + bucket := getEnv("S3_PROFILE_PICS_BUCKET", "crussell-profile-pics") + + var oldURL sql.NullString + err = db.DB.QueryRow(r.Context(), `SELECT profile_pic_url FROM users WHERE id = $1`, userID).Scan(&oldURL) + if err == nil && oldURL.Valid && oldURL.String != "" { + if delErr := s3.Client.Delete(r.Context(), bucket, key); delErr != nil { + log.Printf("Warning: Failed to delete old profile picture for user %s: %v", userID, delErr) + } + } fileBytes, err = processProfileImage(fileBytes) if err != nil { @@ -853,8 +966,6 @@ func UploadProfilePictureHandler(w http.ResponseWriter, r *http.Request) { return } - bucket := getEnv("S3_PROFILE_PICS_BUCKET", "crussell-profile-pics") - if err := s3.Client.Upload(r.Context(), bucket, key, bytes.NewReader(fileBytes), "image/jpeg"); err != nil { log.Printf("Failed to upload profile picture to S3: %v", err) http.Error(w, "Failed to upload image", http.StatusInternalServerError) @@ -932,7 +1043,7 @@ func GetNotificationPreferencesHandler(w http.ResponseWriter, r *http.Request) { `, userID).Scan(&prefs.EmailEnabled, &prefs.SMSEnabled, &prefs.BrowserPushEnabled) if err != nil { - if errors.Is(err, sql.ErrNoRows) { + if errors.Is(err, pgx.ErrNoRows) { prefs = NotificationPreferencesResponse{ EmailEnabled: true, SMSEnabled: true, diff --git a/backend/internal/validators/validators.go b/backend/internal/validators/validators.go index c54bc19..5de7c18 100644 --- a/backend/internal/validators/validators.go +++ b/backend/internal/validators/validators.go @@ -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 +}