Split admin dashboard, implement user and booking search

This commit is contained in:
2026-01-17 18:55:58 +00:00
parent 1e8995f577
commit 3a77e582e3
17 changed files with 4315 additions and 3266 deletions
+99 -85
View File
@@ -170,10 +170,11 @@ type GetAllBookingsRequest struct {
// BookingListResponse represents a paginated list of bookings
type BookingListResponse struct {
Bookings []Booking `json:"bookings"`
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int `json:"total"`
Bookings []Booking `json:"bookings"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalPages int `json:"totalPages"`
}
// SearchBookingsRequest represents search parameters
@@ -573,76 +574,53 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Parse query parameters
req := parseGetAllBookingsRequest(r)
// Parse pagination parameters
query := r.URL.Query()
page := 1
perPage := 5
// Build query with specific user filter
baseQuery := `
SELECT id, user_id, start_time, status, notes, created_at, updated_at, created_by,
u.fn, u.profile_pic_url, u.notes as user_notes
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE b.user_id = $1
`
countQuery := `SELECT COUNT(*) FROM bookings WHERE user_id = $1`
var args []interface{}
args = append(args, userID)
paramCount := 2
// Add filters
if req.Status != nil {
baseQuery += fmt.Sprintf(" AND b.status = $%d", paramCount)
countQuery += fmt.Sprintf(" AND status = $%d", paramCount)
args = append(args, *req.Status)
paramCount++
}
if req.StartDate != nil {
baseQuery += fmt.Sprintf(" AND b.start_time >= $%d", paramCount)
countQuery += fmt.Sprintf(" AND start_time >= $%d", paramCount)
startTime, err := time.ParseInLocation("2006-01-02", *req.StartDate, londonLocation)
if err != nil {
http.Error(w, "Invalid start_date format, use YYYY-MM-DD", http.StatusBadRequest)
return
if pageStr := query.Get("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
// Ensure it's at start of day in London time
startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), 0, 0, 0, 0, londonLocation)
args = append(args, startTime)
paramCount++
}
if req.EndDate != nil {
baseQuery += fmt.Sprintf(" AND b.start_time <= $%d", paramCount)
countQuery += fmt.Sprintf(" AND start_time <= $%d", paramCount)
endTime, err := time.Parse("2006-01-02", *req.EndDate)
if err != nil {
http.Error(w, "Invalid end_date format, use YYYY-MM-DD", http.StatusBadRequest)
return
if perPageStr := query.Get("per_page"); perPageStr != "" {
if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
perPage = pp
}
endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
args = append(args, endTime)
paramCount++
}
// Add ordering and pagination
baseQuery += " ORDER BY b.start_time ASC"
if req.PerPage > 0 {
baseQuery += fmt.Sprintf(" LIMIT $%d OFFSET $%d", paramCount, paramCount+1)
args = append(args, req.PerPage, (req.Page-1)*req.PerPage)
}
offset := (page - 1) * perPage
// Get total count
var total int
err := db.DB.QueryRow(r.Context(), countQuery, args[:1]...).Scan(&total)
err := db.DB.QueryRow(r.Context(), `
SELECT COUNT(*)
FROM bookings
WHERE user_id = $1
`, userID).Scan(&total)
if err != nil {
log.Printf("Failed to get booking count for user %s: %v", userID, err)
log.Printf("Failed to count bookings for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Get bookings
rows, err := db.DB.Query(r.Context(), baseQuery, args...)
// Fetch bookings with pagination, ordered by start_time DESC (future to past)
rows, err := db.DB.Query(r.Context(), `
SELECT
b.id,
b.start_time,
b.status,
b.notes,
b.created_at,
b.updated_at,
b.created_by
FROM bookings b
WHERE b.user_id = $1
ORDER BY b.start_time DESC
LIMIT $2 OFFSET $3
`, userID, perPage, offset)
if err != nil {
log.Printf("Failed to fetch bookings for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -653,45 +631,81 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
var bookings []Booking
for rows.Next() {
var b Booking
b.User = &UserSummary{}
var createdBy sql.NullString
var userFullName, userPicURL, userNotes sql.NullString
err := rows.Scan(
&b.ID, &b.User.ID, &b.StartTime, &b.Status, &b.Notes,
&b.CreatedAt, &b.UpdatedAt, &createdBy,
&userFullName, &userPicURL, &userNotes,
&b.ID,
&b.StartTime,
&b.Status,
&b.Notes,
&b.CreatedAt,
&b.UpdatedAt,
&b.CreatedBy,
)
if userFullName.Valid {
b.User.FullName = userFullName.String
}
if userPicURL.Valid {
b.User.ProfilePicURL = &userPicURL.String
}
if userNotes.Valid {
b.User.Notes = &userNotes.String
}
if err != nil {
log.Printf("Failed to scan booking row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if createdBy.Valid {
b.CreatedBy = &createdBy.String
// Fetch services for this booking
serviceRows, err := db.DB.Query(r.Context(), `
SELECT
s.name,
COALESCE(bs.override_price, s.price) as price,
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
`, b.ID)
if err != nil {
log.Printf("Failed to fetch services for booking %s: %v", b.ID, err)
continue
}
var totalAmount float64
for serviceRows.Next() {
var service BookingService
var price float64
var durationMinutes int
if err := serviceRows.Scan(&service.ServiceName, &price, &durationMinutes); err != nil {
log.Printf("Failed to scan service: %v", err)
continue
}
service.Price = &price
service.DurationMinutes = &durationMinutes
totalAmount += price
b.Services = append(b.Services, service)
}
serviceRows.Close()
b.TotalAmount = totalAmount
bookings = append(bookings, b)
}
if bookings == nil {
bookings = []Booking{}
}
// Calculate total pages
totalPages := (total + perPage - 1) / perPage
if totalPages == 0 {
totalPages = 1
}
response := BookingListResponse{
Bookings: bookings,
Page: req.Page,
PerPage: req.PerPage,
Total: total,
Bookings: bookings,
Total: total,
Page: page,
PerPage: perPage,
TotalPages: totalPages,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Failed to encode response: %v", err)
log.Printf("Failed to encode bookings response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
@@ -773,7 +787,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
defer serviceRows.Close()
var totalAmount float64
var durationMinutes int
var durationMinutesTotal int
for serviceRows.Next() {
var name string
@@ -788,7 +802,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
// Calculate totals
totalAmount += price
durationMinutes += durationMinutes
durationMinutesTotal += durationMinutes
booking.Services = append(booking.Services, BookingService{
ServiceName: &name,
Price: &price,
@@ -797,7 +811,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
}
booking.TotalAmount = totalAmount
booking.DurationMinutes = durationMinutes
booking.DurationMinutes = durationMinutesTotal
// ----------------------------
// 3. Fetch payments and calculate amount paid
+48
View File
@@ -0,0 +1,48 @@
package portfolio
// import (
// "crussell/db"
// "net/http"
// )
// func GetImages(w http.ResponseWriter, r *http.Request) {
// rows, err := db.DB.Query(r.Context(), `
// select id, r2_url
// from images
// where
// ($1::text[] is null or tag_names @> $1)
// and ($2::text is null or exists (
// select 1 from unnest(tag_names) as tag
// where tag ilike '%' || $2 || '%'
// ))
// order by created_at desc
// limit $3 offset $4
// `,
// semanticTags,
// searchTerm,
// limit,
// offset,
// )
// }
// func getAutoCompleteAdmin(w http.ResponseWriter, r *http.Request) {
// rows, err := db.DB.Query(r.Context(), `
// select name
// from tags
// where name ilike '%' || $1 || '%'
// order by similarity(name, $1) desc
// limit 10
// `, query)
// }
// func getAutoCompleteUser(w http.ResponseWriter, r *http.Request) {
// rows, err := db.DB.Query(r.Context(), `
// select name
// from tags
// where
// name not like '%:%'
// and name ilike '%' || $1 || '%'
// order by similarity(name, $1) desc
// limit 10
// `, query)
// }
+266
View File
@@ -2,13 +2,17 @@ package user
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/text/cases"
"golang.org/x/text/language"
@@ -39,6 +43,55 @@ type UpdateProfileRequest struct {
Phone string `json:"phone"`
}
type AdminUserDetail struct {
ID string `json:"id"`
Email *string `json:"email,omitempty"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
FullName string `json:"fullName"`
Phone *string `json:"phone,omitempty"`
DateOfBirth *string `json:"dateOfBirth,omitempty"`
ProfilePicURL *string `json:"profilePicUrl,omitempty"`
AccountRole string `json:"accountRole"`
AccountType string `json:"accountType"`
LoyaltyStamps int `json:"loyaltyStamps"`
ReferralCode string `json:"referralCode"`
ReferralCodeUses int `json:"referralCodeUses"`
LastLoginAt *string `json:"lastLoginAt,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Notes *string `json:"notes,omitempty"`
// GDPR consent fields
PrivacyPolicyConsent bool `json:"privacyPolicyConsent"`
PolicyConsentUpdatedAt *string `json:"policyConsentUpdatedAt,omitempty"`
DataRetentionConsent bool `json:"dataRetentionConsent"`
DataConsentUpdatedAt *string `json:"dataConsentUpdatedAt,omitempty"`
// Social logins
SocialLogins []SocialLogin `json:"socialLogins,omitempty"`
}
type SocialLogin struct {
Provider string `json:"provider"`
CreatedAt string `json:"createdAt"`
}
type UserListItem struct {
ID string `json:"id"`
FullName string `json:"fullName"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
}
type UserListResponse struct {
Users []UserListItem `json:"users"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalPages int `json:"totalPages"`
}
// GET /api/user/profile
func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context())
@@ -209,3 +262,216 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// GET /api/admin/users/{id}
func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) {
userID := chi.URLParam(r, "id")
if userID == "" {
http.Error(w, "User ID is required", http.StatusBadRequest)
return
}
// Fetch user details
var user AdminUserDetail
err := db.DB.QueryRow(r.Context(), `
SELECT
id, email, n_first_name, n_last_name, fn, phone,
date_of_birth::text, profile_pic_url,
account_role, account_type, loyalty_stamps, referral_code,
last_login_at::text, created_at::text, updated_at::text, notes,
privacy_policy_and_terms_consent,
policy_consent_updated_at::text,
data_retention_consent,
data_consent_updated_at::text
FROM users
WHERE id = $1
`, userID).Scan(
&user.ID, &user.Email, &user.FirstName, &user.LastName, &user.FullName,
&user.Phone, &user.DateOfBirth, &user.ProfilePicURL,
&user.AccountRole, &user.AccountType, &user.LoyaltyStamps, &user.ReferralCode,
&user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, &user.Notes,
&user.PrivacyPolicyConsent, &user.PolicyConsentUpdatedAt,
&user.DataRetentionConsent, &user.DataConsentUpdatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "User not found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Fetch referral code uses count
err = db.DB.QueryRow(r.Context(), `
SELECT COUNT(*)
FROM user_referrals
WHERE referrer_id = $1 AND claimed_booking_id IS NOT NULL
`, userID).Scan(&user.ReferralCodeUses)
if err != nil {
log.Printf("Failed to fetch referral code uses for user %s: %v", userID, err)
user.ReferralCodeUses = 0
}
// Fetch social logins
socialRows, err := db.DB.Query(r.Context(), `
SELECT provider, created_at::text
FROM user_social_logins
WHERE user_id = $1
ORDER BY created_at ASC
`, userID)
if err != nil {
log.Printf("Failed to fetch social logins for user %s: %v", userID, err)
} else {
defer socialRows.Close()
var socialLogins []SocialLogin
for socialRows.Next() {
var sl SocialLogin
if err := socialRows.Scan(&sl.Provider, &sl.CreatedAt); err != nil {
log.Printf("Failed to scan social login for user %s: %v", userID, err)
continue
}
socialLogins = append(socialLogins, sl)
}
if len(socialLogins) > 0 {
user.SocialLogins = socialLogins
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(user); err != nil {
log.Printf("Failed to encode user response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// GET /api/admin/users
func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
// Parse query parameters
query := r.URL.Query()
searchTerm := query.Get("q")
// Pagination parameters
page := 1
perPage := 10
if pageStr := query.Get("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
if perPageStr := query.Get("per_page"); perPageStr != "" {
if pp, err := strconv.Atoi(perPageStr); err == nil && pp > 0 && pp <= 100 {
perPage = pp
}
}
offset := (page - 1) * perPage
// Build query based on whether search is provided
var countQuery string
var listQuery string
var countArgs []interface{}
var listArgs []interface{}
if searchTerm != "" {
// Search in name, email, or phone
searchPattern := "%" + searchTerm + "%"
countQuery = `
SELECT COUNT(*)
FROM users
WHERE fn ILIKE $1
OR email ILIKE $1
OR phone ILIKE $1
`
countArgs = []interface{}{searchPattern}
listQuery = `
SELECT id, fn, email, phone
FROM users
WHERE fn ILIKE $1
OR email ILIKE $1
OR phone ILIKE $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
listArgs = []interface{}{searchPattern, perPage, offset}
} else {
// No search - get all users
countQuery = `SELECT COUNT(*) FROM users`
countArgs = []interface{}{}
listQuery = `
SELECT id, fn, email, phone
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`
listArgs = []interface{}{perPage, offset}
}
// Get total count
var total int
err := db.DB.QueryRow(r.Context(), countQuery, countArgs...).Scan(&total)
if err != nil {
log.Printf("Failed to count users: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Get users list
rows, err := db.DB.Query(r.Context(), listQuery, listArgs...)
if err != nil {
log.Printf("Failed to fetch users: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var users []UserListItem
for rows.Next() {
var user UserListItem
err := rows.Scan(&user.ID, &user.FullName, &user.Email, &user.Phone)
if err != nil {
log.Printf("Failed to scan user row: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
users = append(users, user)
}
// Handle empty results
if users == nil {
users = []UserListItem{}
}
// Calculate total pages
totalPages := (total + perPage - 1) / perPage
if totalPages == 0 {
totalPages = 1
}
response := UserListResponse{
Users: users,
Total: total,
Page: page,
PerPage: perPage,
TotalPages: totalPages,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Printf("Failed to encode users response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
+6
View File
@@ -132,6 +132,12 @@ func main() {
r.Get("/{id}", bookings.GetAdminBookingHandler)
r.Put("/{id}/progress", bookings.ProgressBookingHandler)
r.Post("/{id}/confirm", bookings.ConfirmBookingHandler)
r.Post("/{id}/cancel", bookings.ConfirmBookingHandler)
})
r.Route("/admin/users", func(r chi.Router) {
r.Get("/", user.ListAdminUsersHandler)
r.Get("/{id}", user.GetAdminUserHandler)
})
// --- Admin Notifications ---