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
+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)
}