diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index e7f3850..fb3590b 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -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,39 +866,67 @@ 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 JOIN bookings b ON ber.booking_id = b.id JOIN users u ON ber.requested_by = u.id ` - + 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, ¬es, &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) diff --git a/backend/main.go b/backend/main.go index c6b5408..d7c5750 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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) { diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index 544224d..686946f 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -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;