partial edit fixes

This commit is contained in:
2026-02-26 20:03:11 +00:00
parent 4fc84e6d39
commit 72124bac98
3 changed files with 438 additions and 103 deletions
+316 -84
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -372,12 +373,8 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
// Clear any pending edit requests for this booking (admin edit takes priority)
_, err = db.DB.Exec(r.Context(), `
DELETE FROM booking_edit_requests
WHERE booking_id = $1 AND status = 'pending'
WHERE booking_id = $1
`, bookingID)
if err != nil {
log.Printf("Failed to clear edit requests for booking %s: %v", bookingID, err)
// Don't fail the request, just log the error
}
// Return warnings if any
if len(warnings) > 0 {
@@ -685,20 +682,72 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
// BookingEditRequest represents a user's request to edit a booking
type BookingEditRequest struct {
ID string `json:"id"`
BookingID string `json:"booking_id"`
RequestedStartTime time.Time `json:"requested_start_time"`
Status string `json:"status"`
AdminNotes *string `json:"admin_notes,omitempty"`
RequestedBy string `json:"requested_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
BookingID string `json:"booking_id"`
RequestedBy string `json:"requested_by"`
NewStartTime *time.Time `json:"new_start_time,omitempty"`
NewServices []string `json:"new_services"`
Notes *string `json:"notes,omitempty"`
HasOverrides bool `json:"has_overrides"`
UpdatedAt time.Time `json:"updated_at"`
// Joined fields
Booking *Booking `json:"booking,omitempty"`
Booking *Booking `json:"booking,omitempty"`
User *UserSummary `json:"user,omitempty"`
}
// DeleteEditRequestHandler allows a user to delete/cancel their pending edit request
func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
// RequestEditHandler allows a user to request an edit to their booking's start time
userID, ok := r.Context().Value(mw.UserIDKey).(string)
if !ok || userID == "" {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
// Verify user owns this booking
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) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
}
log.Printf("Failed to get booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if ownerID != userID {
http.Error(w, "Access denied", http.StatusForbidden)
return
}
// Delete the edit request for this booking
res, err := db.DB.Exec(r.Context(), `
DELETE FROM booking_edit_requests
WHERE booking_id = $1 AND requested_by = $2
`, bookingID, userID)
if err != nil {
log.Printf("Failed to delete edit request for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
rowsAffected := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "No edit request found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
}
// RequestEditHandler allows a user to request an edit to their booking
func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
bookingID := chi.URLParam(r, "id")
if bookingID == "" || !validators.IsValidID(bookingID) {
@@ -713,13 +762,21 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
}
var req struct {
RequestedStartTime time.Time `json:"requested_start_time" validate:"required"`
NewStartTime *time.Time `json:"new_start_time,omitempty"`
NewServices []string `json:"new_services"`
Notes *string `json:"notes,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate: at least one of new_start_time, new_services, or notes must be provided
if req.NewStartTime == nil && len(req.NewServices) == 0 && req.Notes == nil {
http.Error(w, "At least one of new_start_time, new_services, or notes is required", http.StatusBadRequest)
return
}
// Verify user owns this booking
var ownerID string
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
@@ -751,20 +808,56 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Cannot edit a completed or cancelled booking", http.StatusForbidden)
return
}
if len(req.NewServices) > 0 {
var overrideCount int
err = db.DB.QueryRow(r.Context(), `
SELECT COUNT(*) FROM booking_services
WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL)
`, bookingID).Scan(&overrideCount)
if err != nil {
log.Printf("Failed to check overrides for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if overrideCount > 0 {
http.Error(w, "Cannot change services on a booking that has overrides. Please contact the salon.", http.StatusForbidden)
return
}
}
// Create edit request
tx, err := db.DB.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer tx.Rollback(r.Context())
// Delete any existing edit request for this booking (upsert behavior)
_, err = tx.Exec(r.Context(), `
DELETE FROM booking_edit_requests
WHERE booking_id = $1 AND requested_by = $2
`, bookingID, userID)
if err != nil {
log.Printf("Failed to delete existing edit request for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Create edit request - has_overrides is false since user can't override
var editReq BookingEditRequest
err = db.DB.QueryRow(r.Context(), `
INSERT INTO booking_edit_requests (booking_id, requested_start_time, requested_by)
VALUES ($1, $2, $3)
RETURNING id, booking_id, requested_start_time, status, requested_by, created_at, updated_at
`, bookingID, req.RequestedStartTime, userID).Scan(
err = tx.QueryRow(r.Context(), `
INSERT INTO booking_edit_requests (booking_id, requested_by, new_start_time, new_services, notes, has_overrides)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
`, bookingID, userID, req.NewStartTime, req.NewServices, req.Notes, false).Scan(
&editReq.ID,
&editReq.BookingID,
&editReq.RequestedStartTime,
&editReq.Status,
&editReq.RequestedBy,
&editReq.CreatedAt,
&editReq.NewStartTime,
&editReq.NewServices,
&editReq.Notes,
&editReq.HasOverrides,
&editReq.UpdatedAt,
)
if err != nil {
@@ -773,19 +866,55 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Create admin notification with reason 'edit_request'
_, err = tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
`, "edit_request", bookingID, userID)
if err != nil {
log.Printf("Failed to create admin notification for edit request %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// If booking status is 'pending', acknowledge existing pending_booking notification and create new one
if currentStatus == "pending" {
// Acknowledge existing pending_booking notification
_, err = tx.Exec(r.Context(), `
UPDATE admin_notifications
SET acknowledged_at = NOW()
WHERE booking_id = $1 AND reason = 'pending_booking' AND acknowledged_at IS NULL
`, bookingID)
if err != nil {
log.Printf("Failed to acknowledge pending booking notification for %s: %v", bookingID, err)
}
// Create new pending_booking notification (admin will see the edit request)
_, err = tx.Exec(r.Context(), `
INSERT INTO admin_notifications (reason, booking_id, user_id)
VALUES ($1, $2, $3)
`, "pending_booking", bookingID, userID)
if err != nil {
log.Printf("Failed to create pending booking notification for %s: %v", bookingID, err)
}
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit edit request: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(editReq)
}
// AdminListEditRequestsHandler returns all pending edit requests
// AdminListEditRequestsHandler returns all edit requests
func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
status := query.Get("status")
baseQuery := `
SELECT ber.id, ber.booking_id, ber.requested_start_time, ber.status,
ber.admin_notes, ber.requested_by, ber.created_at, ber.updated_at,
SELECT ber.id, ber.booking_id, ber.requested_by, ber.new_start_time,
ber.new_services, ber.notes, ber.has_overrides, ber.updated_at,
b.start_time as original_start_time, b.status as booking_status,
u.fn as user_name
FROM booking_edit_requests ber
@@ -796,16 +925,8 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
countQuery := `SELECT COUNT(*) FROM booking_edit_requests ber`
var args []interface{}
paramCount := 1
if status != "" {
baseQuery += fmt.Sprintf(" WHERE ber.status = $%d", paramCount)
countQuery += fmt.Sprintf(" WHERE ber.status = $%d", paramCount)
args = append(args, status)
paramCount++
}
baseQuery += " ORDER BY ber.created_at DESC"
baseQuery += " ORDER BY ber.updated_at DESC"
// Get total count
var total int
@@ -830,15 +951,16 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
var origStartTime time.Time
var bookingStatus string
var userName string
var newServicesJSON []byte
err := rows.Scan(
&req.ID,
&req.BookingID,
&req.RequestedStartTime,
&req.Status,
&req.AdminNotes,
&req.RequestedBy,
&req.CreatedAt,
&req.NewStartTime,
&newServicesJSON,
&req.Notes,
&req.HasOverrides,
&req.UpdatedAt,
&origStartTime,
&bookingStatus,
@@ -849,6 +971,13 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
continue
}
// Unmarshal JSON services
if len(newServicesJSON) > 0 {
if err := json.Unmarshal(newServicesJSON, &req.NewServices); err != nil {
log.Printf("Failed to unmarshal new_services: %v", err)
}
}
req.Booking = &Booking{
ID: req.BookingID,
StartTime: origStartTime,
@@ -881,14 +1010,6 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
AdminNotes *string `json:"admin_notes,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
tx, err := db.DB.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
@@ -899,14 +1020,18 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
// Get the edit request
var bookingID string
var newStartTime time.Time
var newStartTime *time.Time
var newServicesJSON []byte
var notes *string
var hasOverrides bool
err = tx.QueryRow(r.Context(), `
SELECT booking_id, requested_start_time FROM booking_edit_requests
WHERE id = $1 AND status = 'pending'
`, requestID).Scan(&bookingID, &newStartTime)
SELECT booking_id, new_start_time, new_services, notes, has_overrides
FROM booking_edit_requests
WHERE id = $1
`, requestID).Scan(&bookingID, &newStartTime, &newServicesJSON, &notes, &hasOverrides)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Edit request not found or already processed", http.StatusNotFound)
http.Error(w, "Edit request not found", http.StatusNotFound)
return
}
log.Printf("Failed to get edit request %s: %v", requestID, err)
@@ -914,24 +1039,140 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Update the booking
_, err = tx.Exec(r.Context(), `
UPDATE bookings SET start_time = $1, updated_at = NOW() WHERE id = $2
`, newStartTime, bookingID)
if err != nil {
log.Printf("Failed to update booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
// Parse new_services
var newServices []string
if len(newServicesJSON) > 0 {
if err := json.Unmarshal(newServicesJSON, &newServices); err != nil {
log.Printf("Failed to unmarshal new_services: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// If new_services provided and has_overrides is true, block with error
if len(newServices) > 0 && hasOverrides {
http.Error(w, "Cannot change services on a booking that has overrides. Please update services manually.", http.StatusForbidden)
return
}
// Mark request as approved
_, err = tx.Exec(r.Context(), `
UPDATE booking_edit_requests
SET status = 'approved', admin_notes = $1, updated_at = NOW()
WHERE id = $2
`, req.AdminNotes, requestID)
// Calculate duration for overlap check - use overrides if has_overrides is true
var durationMinutes int
if hasOverrides {
// Use the existing booking_services with overrides
err = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
`, bookingID).Scan(&durationMinutes)
} else {
// Use standard durations or new_services if provided
if len(newServices) > 0 {
// Use new services to calculate duration
err = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60)
FROM services s
WHERE s.id = ANY($1)
`, newServices).Scan(&durationMinutes)
} else {
// Use existing booking services
err = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
`, bookingID).Scan(&durationMinutes)
}
}
if err != nil {
log.Printf("Failed to update edit request %s: %v", requestID, err)
log.Printf("Failed to calculate duration: %v", err)
durationMinutes = 60 // fallback
}
// Check for overlapping bookings if start time is being changed
if newStartTime != nil {
newEndTime := newStartTime.Add(time.Duration(durationMinutes) * time.Minute)
var overlapCount int
err = tx.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings
WHERE id != $1
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled')
AND start_time < $3
AND start_time + (INTERVAL '1 minute' * (
SELECT COALESCE(SUM(COALESCE(bs.override_duration_minutes, s.duration_minutes)), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = bookings.id
)) > $2
`, bookingID, *newStartTime, newEndTime).Scan(&overlapCount)
if err != nil {
log.Printf("Failed to check overlap: %v", err)
}
if overlapCount > 0 {
http.Error(w, "This edit would cause an overlap with an existing booking", http.StatusConflict)
return
}
}
// Build update query for bookings table
if newStartTime != nil || notes != nil {
var setClauses []string
var args []interface{}
argNum := 1
if newStartTime != nil {
setClauses = append(setClauses, fmt.Sprintf("start_time = $%d", argNum))
args = append(args, *newStartTime)
argNum++
}
if notes != nil {
setClauses = append(setClauses, fmt.Sprintf("notes = $%d", argNum))
args = append(args, *notes)
argNum++
}
setClauses = append(setClauses, fmt.Sprintf("updated_at = $%d", argNum))
args = append(args, time.Now())
argNum++
args = append(args, bookingID)
query := fmt.Sprintf("UPDATE bookings SET %s WHERE id = $%d", strings.Join(setClauses, ", "), argNum)
_, err = tx.Exec(r.Context(), query, args...)
if err != nil {
log.Printf("Failed to update booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// Handle new_services (only if has_overrides is false)
if len(newServices) > 0 && !hasOverrides {
// Delete existing booking_services
_, err = tx.Exec(r.Context(), "DELETE FROM booking_services WHERE booking_id = $1", bookingID)
if err != nil {
log.Printf("Failed to delete existing services for booking %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Insert new services
for _, serviceID := range newServices {
_, err = tx.Exec(r.Context(), `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
if err != nil {
log.Printf("Failed to insert booking service %s: %v", serviceID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
}
// Delete the edit request row (not update status)
_, err = tx.Exec(r.Context(), "DELETE FROM booking_edit_requests WHERE id = $1", requestID)
if err != nil {
log.Printf("Failed to delete edit request %s: %v", requestID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
@@ -945,7 +1186,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// AdminRejectEditRequestHandler rejects an edit request
// AdminRejectEditRequestHandler rejects an edit request by deleting it (deny without notification)
func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
requestID := chi.URLParam(r, "request_id")
if requestID == "" || !validators.IsValidID(requestID) {
@@ -953,19 +1194,10 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
return
}
var req struct {
AdminNotes string `json:"admin_notes" validate:"required"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
_, err := db.DB.Exec(r.Context(), `
UPDATE booking_edit_requests
SET status = 'rejected', admin_notes = $1, updated_at = NOW()
WHERE id = $2 AND status = 'pending'
`, req.AdminNotes, requestID)
DELETE FROM booking_edit_requests
WHERE id = $1
`, requestID)
if err != nil {
log.Printf("Failed to reject edit request %s: %v", requestID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
+7
View File
@@ -158,6 +158,9 @@ func main() {
r.Get("/{id}/calendar", bookings.GetBookingCalendarHandler)
r.Put("/{id}", bookings.EditBookingHandler)
r.Delete("/{id}", bookings.DeleteBookingHandler)
// Edit request endpoints
r.Post("/{id}/edit-request", bookings.RequestEditHandler)
r.Delete("/{id}/edit-request", bookings.DeleteEditRequestHandler)
})
})
@@ -182,6 +185,10 @@ func main() {
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
r.Post("/{id}/cancel", bookings.CancelBookingHandler)
// Edit request endpoints
r.Get("/{id}/edit-requests", bookings.AdminListEditRequestsHandler)
r.Post("/{id}/edit-requests/{request_id}/approve", bookings.AdminApproveEditRequestHandler)
r.Post("/{id}/edit-requests/{request_id}/deny", bookings.AdminRejectEditRequestHandler)
})
r.Route("/admin/users", func(r chi.Router) {
+113 -17
View File
@@ -232,6 +232,47 @@ CREATE TABLE booking_services (
PRIMARY KEY (booking_id, service_id)
);
-- =======================================
-- BOOKING EDIT REQUESTS TABLE
-- =======================================
CREATE TABLE booking_edit_requests (
id CHAR(12) PRIMARY KEY DEFAULT generate_booking_id(),
booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
requested_by CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
new_start_time TIMESTAMPTZ,
new_services JSONB DEFAULT '[]'::jsonb, -- Array of service IDs to replace booking_services
notes TEXT,
has_overrides BOOLEAN NOT NULL DEFAULT FALSE, -- If TRUE, cannot change services, use existing overrides for duration
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_at_least_one_field CHECK (
new_start_time IS NOT NULL OR
new_services IS NOT NULL OR
notes IS NOT NULL
)
);
CREATE INDEX idx_booking_edit_requests_booking ON booking_edit_requests(booking_id);
id CHAR(12) PRIMARY KEY DEFAULT generate_booking_id(),
booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
requested_by CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
new_start_time TIMESTAMPTZ,
new_services JSONB DEFAULT '[]'::jsonb,
new_notes TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
admin_notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_at_least_one_field CHECK (
new_start_time IS NOT NULL OR
new_services IS NOT NULL OR
new_notes IS NOT NULL
)
);
CREATE INDEX idx_booking_edit_requests_booking ON booking_edit_requests(booking_id);
CREATE INDEX idx_booking_edit_requests_status ON booking_edit_requests(status);
CREATE TABLE user_referrals (
referrer_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
referred_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -384,7 +425,7 @@ INSERT INTO business_settings (
'https://www.website.co.uk'
);
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'no_deposit', 'deposit_paid');
CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'no_deposit', 'deposit_paid', 'edit_request');
CREATE TABLE admin_notifications (
id SERIAL PRIMARY KEY,
@@ -504,21 +545,26 @@ BEGIN
SELECT json_build_object(
'user_profile', (
SELECT json_build_object(
'id', id,
'first_name', n_first_name,
'last_name', n_last_name,
'full_name', fn,
'email', email,
'phone', phone,
'date_of_birth', date_of_birth,
'profile_pic_url', profile_pic_url,
'account_role', account_role,
'loyalty_stamps', loyalty_stamps,
'referral_code', referral_code,
'data_retention_consent', data_retention_consent,
'data_consent_updated_at', data_consent_updated_at,
'created_at', created_at,
'updated_at', updated_at
'id', id,
'first_name', n_first_name,
'last_name', n_last_name,
'full_name', fn,
'email', email,
'phone', phone,
'date_of_birth', date_of_birth,
'profile_pic_url', profile_pic_url,
'account_role', account_role,
'account_type', account_type,
'last_login_at', last_login_at,
'loyalty_stamps', loyalty_stamps,
'deposits_required', deposits_required,
'referral_code', referral_code,
'privacy_policy_and_terms_consent', privacy_policy_and_terms_consent,
'policy_consent_updated_at', policy_consent_updated_at,
'data_retention_consent', data_retention_consent,
'data_consent_updated_at', data_consent_updated_at,
'created_at', created_at,
'updated_at', updated_at
)
FROM users WHERE id = target_user_id
),
@@ -529,6 +575,7 @@ BEGIN
'start_time', b.start_time,
'status', b.status,
'notes', b.notes,
'created_by', b.created_by,
'created_at', b.created_at,
'updated_at', b.updated_at,
'services', (
@@ -556,6 +603,7 @@ BEGIN
'booking_id', p.booking_id,
'payment_type', p.payment_type,
'payment_method', p.payment_method,
'vendor_code', p.vendor_code,
'invoice_number', p.invoice_number,
'status', p.status,
'amount', p.amount,
@@ -582,11 +630,59 @@ BEGIN
JOIN patch_tests pt ON upt.patch_test_id = pt.id
WHERE upt.user_id = target_user_id
),
'referrals', (
SELECT json_build_object(
'referred_by', (
-- The user who referred this user (if any)
-- First name only: referrer's name is their own personal data,
-- included here only to make the SAR meaningful to the recipient.
SELECT json_build_object(
'referrer_id', ur.referrer_id,
'referrer_first_name', u.n_first_name,
'referred_at', ur.referred_at,
'claimed_booking_id', ur.claimed_booking_id
)
FROM user_referrals ur
JOIN users u ON u.id = ur.referrer_id
WHERE ur.referred_id = target_user_id
LIMIT 1
),
'referred_users', (
-- Users this person has referred
-- First name only: same reasoning as above.
SELECT COALESCE(json_agg(
json_build_object(
'referred_id', ur.referred_id,
'referred_first_name', u.n_first_name,
'referred_at', ur.referred_at,
'claimed_booking_id', ur.claimed_booking_id
)
ORDER BY ur.referred_at DESC), '[]'::json)
FROM user_referrals ur
JOIN users u ON u.id = ur.referred_id
WHERE ur.referrer_id = target_user_id
)
)
),
'notification_preferences', (
SELECT COALESCE(json_agg(
json_build_object(
'notification_type', unp.notification_type,
'email_enabled', unp.email_enabled,
'sms_enabled', unp.sms_enabled,
'push_enabled', unp.push_enabled,
'created_at', unp.created_at,
'updated_at', unp.updated_at
)
ORDER BY unp.notification_type), '[]'::json)
FROM user_notification_preferences unp
WHERE unp.user_id = target_user_id
),
'export_metadata', json_build_object(
'exported_at', NOW(),
'exported_by', 'system',
'user_id', target_user_id,
'format_version', '1.0'
'format_version', '1.1'
)
) INTO result;