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:
+163
-77
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user