refactor(backend): migrate db.DB to db.Conn PoolProxy across all handlers

Replace direct *pgxpool.Pool usage with PoolProxy wrapper across the entire backend:

- db.DB renamed to db.Conn (*pgxpool.Pool -> *PoolProxy)
- JWT functions now accept context.Context instead of using context.Background()
- Handler DB calls route through PoolProxy for per-test transaction support
- Fixture/helper/testdb functions accept Querier interface for decoupling
- Query ordering fixed in bookings handlers: COUNT after data query to avoid pgx conn busy
- Time truncation fixed: time.Date instead of Truncate(24*time.Hour) for week start calc
- testmain_test.go files updated with SeedBaseline and NewPoolProxy

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-21 19:28:54 +01:00
co-authored by Sisyphus
parent c69a243f75
commit 3d0e2afc4c
39 changed files with 751 additions and 638 deletions
+14 -14
View File
@@ -35,11 +35,11 @@ func generateJTI() (string, error) {
}
// RevokeJTI adds a JTI to the revoked set in PostgreSQL
func RevokeJTI(jti string, expiresAt time.Time) {
if db.DB == nil {
func RevokeJTI(ctx context.Context, jti string, expiresAt time.Time) {
if db.Conn == nil {
return
}
_, err := db.DB.Exec(context.Background(),
_, err := db.Conn.Exec(ctx,
`INSERT INTO revoked_jtis (jti, expires_at) VALUES ($1, $2)
ON CONFLICT (jti) DO NOTHING`,
jti, expiresAt)
@@ -53,12 +53,12 @@ func RevokeJTI(jti string, expiresAt time.Time) {
// Returns false if the DB is not initialized (unit tests, startup) — treating
// the token as valid is the safer default for availability over security during
// startup, and revoked checks are quickly re-evaluated on each request.
func IsJTIRevoked(jti string) bool {
if db.DB == nil {
func IsJTIRevoked(ctx context.Context, jti string) bool {
if db.Conn == nil {
return false
}
var exists bool
err := db.DB.QueryRow(context.Background(),
err := db.Conn.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM revoked_jtis WHERE jti = $1 AND expires_at > NOW())`,
jti).Scan(&exists)
if err != nil {
@@ -68,11 +68,11 @@ func IsJTIRevoked(jti string) bool {
}
// CleanupRevokedJTIs removes expired entries from PostgreSQL
func CleanupRevokedJTIs() {
if db.DB == nil {
func CleanupRevokedJTIs(ctx context.Context) {
if db.Conn == nil {
return
}
_, err := db.DB.Exec(context.Background(),
_, err := db.Conn.Exec(ctx,
`DELETE FROM revoked_jtis WHERE expires_at < NOW()`)
if err != nil {
fmt.Printf("WARN: Failed to cleanup revoked JTIs: %v\n", err)
@@ -85,7 +85,7 @@ func StartJTICleanup() {
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
for range ticker.C {
CleanupRevokedJTIs()
CleanupRevokedJTIs(context.Background())
}
}()
}
@@ -145,7 +145,7 @@ func VerifyToken(tokenString string, ctx context.Context) (userID string, role s
return "", "", "", fmt.Errorf("invalid jti claim")
}
if IsJTIRevoked(jti) {
if IsJTIRevoked(ctx, jti) {
return "", "", "", fmt.Errorf("token revoked")
}
@@ -163,7 +163,7 @@ func generateRefreshTokenString() (string, error) {
// GenerateRefreshToken creates a refresh token stored in the database
// Returns the opaque token string to return to the client
func GenerateRefreshToken(userID string, role string) (string, error) {
func GenerateRefreshToken(ctx context.Context, userID string, role string) (string, error) {
token, err := generateRefreshTokenString()
if err != nil {
return "", err
@@ -176,7 +176,7 @@ func GenerateRefreshToken(userID string, role string) (string, error) {
RETURNING id`
var tokenID int64
err = db.DB.QueryRow(context.Background(), query, userID, token, role).Scan(&tokenID)
err = db.Conn.QueryRow(ctx, query, userID, token, role).Scan(&tokenID)
if err != nil {
return "", fmt.Errorf("failed to store refresh token: %w", err)
}
@@ -194,7 +194,7 @@ func VerifyRefreshToken(ctx context.Context, tokenString string) (userID string,
AND NOT revoked
RETURNING user_id, role`
err = db.DB.QueryRow(ctx, query, tokenString).Scan(&userID, &role)
err = db.Conn.QueryRow(ctx, query, tokenString).Scan(&userID, &role)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", "", fmt.Errorf("invalid or expired refresh token")
+3 -4
View File
@@ -11,7 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
var DB *pgxpool.Pool
var Conn *PoolProxy
func Connect() error {
// Connect to Postgres on local network (127.x.x.x)
@@ -28,7 +28,7 @@ func Connect() error {
return err
}
DB = pool
Conn = NewPoolProxy(pool)
err = testDB()
if err != nil {
@@ -40,7 +40,7 @@ func Connect() error {
func testDB() error {
ctx := context.Background()
conn, err := DB.Acquire(ctx)
conn, err := Conn.Acquire(ctx)
if err != nil {
return err
}
@@ -60,6 +60,5 @@ func getEnv(key string) string {
return val
}
// Return empty string instead of fatal error - allows tests to run without prod env vars
// Tests should use testdb.Pool() and set db.DB before running handler code
return ""
}
+3 -3
View File
@@ -11,7 +11,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
var DB *pgxpool.Pool
var Conn *PoolProxy
func Connect() error {
// Connect to Postgres inside Docker network
@@ -27,7 +27,7 @@ func Connect() error {
return err
}
DB = pool
Conn = NewPoolProxy(pool)
err = testDB()
if err != nil {
@@ -39,7 +39,7 @@ func Connect() error {
func testDB() error {
ctx := context.Background()
conn, err := DB.Acquire(ctx)
conn, err := Conn.Acquire(ctx)
if err != nil {
return err
}
+3 -1
View File
@@ -7,6 +7,8 @@ import (
"os"
)
// DB is a compatibility alias for Conn, for tests not yet migrated from db.Conn to db.Conn.
func init() {
// Set defaults for test environment if not already set
if os.Getenv("POSTGRES_USER") == "" {
@@ -22,6 +24,6 @@ func init() {
os.Setenv("POSTGRES_DB", "crussell_test")
}
if os.Getenv("GO_TESTING") == "" {
os.Setenv("GO_TESTING", "true")
os.Setenv("GO_TESTING", "1")
}
}
+20 -19
View File
@@ -73,7 +73,7 @@ func GetCustomServices(w http.ResponseWriter, r *http.Request) {
if err != nil || n <= 0 || n > 20 {
n = 3
}
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT id, name, price, duration_minutes, minimum_age_required, created_at, usage_count
FROM custom_services
WHERE usage_count > 0
@@ -136,7 +136,7 @@ func GetCustomServices(w http.ResponseWriter, r *http.Request) {
dataArgs = append(dataArgs, perPage+1)
}
rows, err := db.DB.Query(r.Context(), dataQuery, dataArgs...)
rows, err := db.Conn.Query(r.Context(), dataQuery, dataArgs...)
if err != nil {
http.Error(w, "Failed to fetch custom services: "+err.Error(), http.StatusInternalServerError)
return
@@ -146,17 +146,6 @@ func GetCustomServices(w http.ResponseWriter, r *http.Request) {
var services []CustomService
var total int64
// Compute total from whichever count query ran above.
if q != "" {
var countTotal int64
db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM custom_services WHERE name ILIKE $1 OR description ILIKE $1", "%"+q+"%").Scan(&countTotal)
total = countTotal
} else {
var countTotal int64
db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM custom_services").Scan(&countTotal)
total = countTotal
}
for rows.Next() {
var cs CustomService
if err := rows.Scan(&cs.ID, &cs.Name, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, &cs.CreatedAt, &cs.UsageCount); err != nil {
@@ -165,6 +154,18 @@ func GetCustomServices(w http.ResponseWriter, r *http.Request) {
services = append(services, cs)
}
// Run count query ONLY after consuming the data query result set,
// so pgx does not return "conn busy" on the same transaction.
if q != "" {
var countTotal int64
db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM custom_services WHERE name ILIKE $1 OR description ILIKE $1", "%"+q+"%").Scan(&countTotal)
total = countTotal
} else {
var countTotal int64
db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM custom_services").Scan(&countTotal)
total = countTotal
}
// nextCursor is set only when we fetched perPage+1 items, proving a next page exists.
var nextCursor *string
if len(services) > perPage {
@@ -229,7 +230,7 @@ func CreateCustomService(w http.ResponseWriter, r *http.Request) {
var desc, notes, createdByDB sql.NullString
var lastUsedAt sql.NullTime
err := db.DB.QueryRow(r.Context(), query, req.Name, req.Description, req.Price, req.DurationMinutes, req.MinimumAgeRequired, req.Notes, createdBy).Scan(
err := db.Conn.QueryRow(r.Context(), query, req.Name, req.Description, req.Price, req.DurationMinutes, req.MinimumAgeRequired, req.Notes, createdBy).Scan(
&cs.ID, &cs.Name, &desc, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, &notes, &cs.CreatedAt, &createdByDB, &cs.UsageCount, &lastUsedAt,
)
if err != nil {
@@ -266,7 +267,7 @@ func GetCustomService(w http.ResponseWriter, r *http.Request) {
var desc, notes, createdBy sql.NullString
var lastUsedAt sql.NullTime
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT id, name, description, price, duration_minutes, minimum_age_required, notes, created_at, created_by, usage_count, last_used_at
FROM custom_services WHERE id = $1
`, id).Scan(&cs.ID, &cs.Name, &desc, &cs.Price, &cs.DurationMinutes, &cs.MinimumAgeRequired, &notes, &cs.CreatedAt, &createdBy, &cs.UsageCount, &lastUsedAt)
@@ -350,7 +351,7 @@ func UpdateCustomService(w http.ResponseWriter, r *http.Request) {
query := "UPDATE custom_services SET " + joinStrings(setClauses, ", ") + " WHERE id = $" + strconv.Itoa(argIdx)
result, err := db.DB.Exec(r.Context(), query, args...)
result, err := db.Conn.Exec(r.Context(), query, args...)
if err != nil {
http.Error(w, "Failed to update custom service: "+err.Error(), http.StatusInternalServerError)
return
@@ -371,7 +372,7 @@ func PromoteCustomService(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "Failed to start transaction", http.StatusInternalServerError)
return
@@ -455,7 +456,7 @@ func DeleteCustomService(w http.ResponseWriter, r *http.Request) {
}
var usageCount int
err := db.DB.QueryRow(r.Context(), `SELECT usage_count FROM custom_services WHERE id = $1`, id).Scan(&usageCount)
err := db.Conn.QueryRow(r.Context(), `SELECT usage_count FROM custom_services WHERE id = $1`, id).Scan(&usageCount)
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Custom service not found", http.StatusNotFound)
return
@@ -469,7 +470,7 @@ func DeleteCustomService(w http.ResponseWriter, r *http.Request) {
return
}
result, err := db.DB.Exec(r.Context(), `DELETE FROM custom_services WHERE id = $1`, id)
result, err := db.Conn.Exec(r.Context(), `DELETE FROM custom_services WHERE id = $1`, id)
if err != nil {
http.Error(w, "Failed to delete custom service: "+err.Error(), http.StatusInternalServerError)
return
+10 -10
View File
@@ -99,7 +99,7 @@ func GetDiscountCampaigns(w http.ResponseWriter, r *http.Request) {
WHERE status = $1
ORDER BY created_at DESC
`
rows, err = db.DB.Query(r.Context(), query, statusFilter)
rows, err = db.Conn.Query(r.Context(), query, statusFilter)
} else {
query = `
SELECT id, name, description, campaign_type, discount_percent, scope,
@@ -108,7 +108,7 @@ func GetDiscountCampaigns(w http.ResponseWriter, r *http.Request) {
FROM discount_campaigns
ORDER BY created_at DESC
`
rows, err = db.DB.Query(r.Context(), query)
rows, err = db.Conn.Query(r.Context(), query)
}
if err != nil {
@@ -295,7 +295,7 @@ func CreateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
var milestoneValue, maxRedemptions sql.NullInt32
var createdByDB sql.NullString
err := db.DB.QueryRow(r.Context(),
err := db.Conn.QueryRow(r.Context(),
query,
req.Name,
req.Description,
@@ -389,7 +389,7 @@ func UpdateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
// Check if campaign exists
var exists bool
err := db.DB.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM discount_campaigns WHERE id = $1)", campaignID).Scan(&exists)
err := db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM discount_campaigns WHERE id = $1)", campaignID).Scan(&exists)
if err != nil {
http.Error(w, "Failed to check campaign: "+err.Error(), http.StatusInternalServerError)
return
@@ -496,7 +496,7 @@ func UpdateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
query += " WHERE id = $" + strconv.Itoa(argNum)
args = append(args, campaignID)
_, err = db.DB.Exec(r.Context(), query, args...)
_, err = db.Conn.Exec(r.Context(), query, args...)
if err != nil {
http.Error(w, "Failed to update campaign: "+err.Error(), http.StatusInternalServerError)
return
@@ -509,7 +509,7 @@ func UpdateDiscountCampaign(w http.ResponseWriter, r *http.Request) {
var milestoneValue, maxRedemptions sql.NullInt32
var createdBy sql.NullString
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT id, name, description, campaign_type, discount_percent, scope,
start_date, end_date, milestone_type, milestone_value, milestone_unit,
status, max_redemptions, times_redeemed, created_at, updated_at, created_by
@@ -595,7 +595,7 @@ func DeleteDiscountCampaign(w http.ResponseWriter, r *http.Request) {
// Check if campaign exists
var exists bool
err := db.DB.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM discount_campaigns WHERE id = $1)", campaignID).Scan(&exists)
err := db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM discount_campaigns WHERE id = $1)", campaignID).Scan(&exists)
if err != nil {
http.Error(w, "Failed to check campaign: "+err.Error(), http.StatusInternalServerError)
return
@@ -607,7 +607,7 @@ func DeleteDiscountCampaign(w http.ResponseWriter, r *http.Request) {
// Soft delete - set status to cancelled
query := "UPDATE discount_campaigns SET status = 'cancelled', updated_at = NOW() WHERE id = $1"
result, err := db.DB.Exec(r.Context(), query, campaignID)
result, err := db.Conn.Exec(r.Context(), query, campaignID)
if err != nil {
http.Error(w, "Failed to delete campaign: "+err.Error(), http.StatusInternalServerError)
return
@@ -641,7 +641,7 @@ func GetCampaignStats(w http.ResponseWriter, r *http.Request) {
var milestoneValue, maxRedemptions sql.NullInt32
var createdBy sql.NullString
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT id, name, description, campaign_type, discount_percent, scope,
start_date, end_date, milestone_type, milestone_value, milestone_unit,
status, max_redemptions, times_redeemed, created_at, updated_at, created_by
@@ -717,7 +717,7 @@ func GetCampaignStats(w http.ResponseWriter, r *http.Request) {
var totalDiscounts float64
var bookingCount int
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT COALESCE(SUM(discount_amount), 0), COUNT(*)
FROM booking_discounts
WHERE source_id = $1
+4 -4
View File
@@ -47,7 +47,7 @@ func GetPatchTests(w http.ResponseWriter, r *http.Request) {
ORDER BY name
`
rows, err := db.DB.Query(r.Context(), query)
rows, err := db.Conn.Query(r.Context(), query)
if err != nil {
http.Error(w, "Failed to fetch patch tests: "+err.Error(), http.StatusInternalServerError)
return
@@ -93,7 +93,7 @@ func CreatePatchTest(w http.ResponseWriter, r *http.Request) {
`
var id string
err := db.DB.QueryRow(r.Context(), query, req.Name, req.Description, req.NoticeDurationHours, req.ExpiryMonths, req.ServiceIDs).Scan(&id)
err := db.Conn.QueryRow(r.Context(), query, req.Name, req.Description, req.NoticeDurationHours, req.ExpiryMonths, req.ServiceIDs).Scan(&id)
if err != nil {
http.Error(w, "Failed to create patch test: "+err.Error(), http.StatusInternalServerError)
return
@@ -156,7 +156,7 @@ func UpdatePatchTest(w http.ResponseWriter, r *http.Request) {
query += " WHERE id = $" + strconv.Itoa(i)
args = append(args, id)
_, err := db.DB.Exec(r.Context(), query, args...)
_, err := db.Conn.Exec(r.Context(), query, args...)
if err != nil {
http.Error(w, "Failed to update patch test", http.StatusInternalServerError)
return
@@ -173,7 +173,7 @@ func DeletePatchTest(w http.ResponseWriter, r *http.Request) {
return
}
_, err := db.DB.Exec(r.Context(), "DELETE FROM patch_tests WHERE id = $1", id)
_, err := db.Conn.Exec(r.Context(), "DELETE FROM patch_tests WHERE id = $1", id)
if err != nil {
http.Error(w, "Failed to delete patch test: "+err.Error(), http.StatusInternalServerError)
return
+2 -2
View File
@@ -24,7 +24,7 @@ type BusinessSettings struct {
func GetBusinessSettings(w http.ResponseWriter, r *http.Request) {
var s BusinessSettings
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT business_name, business_address, business_phone, business_email,
vat_registration_number, is_vat_registered, default_vat_rate,
currency_code, website_url, gift_card_expiry_months, voucher_type
@@ -148,7 +148,7 @@ func UpdateBusinessSettings(w http.ResponseWriter, r *http.Request) {
query += clause
}
_, err := db.DB.Exec(r.Context(), query, args...)
_, err := db.Conn.Exec(r.Context(), query, args...)
if err != nil {
log.Printf("Failed to update business settings: %v", err)
http.Error(w, "Failed to update settings", http.StatusInternalServerError)
+10 -6
View File
@@ -18,19 +18,19 @@ import (
// makeAdminRequest creates a request with admin context
// Note: Using 12-char IDs to match CHAR(12) columns in schema (e.g., created_by)
func makeAdminRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder {
return makeRequestWithContext(handler, method, path, body, "admin001", "admin")
func makeAdminRequest(handler http.Handler, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder {
return makeRequestWithContext(handler, method, path, body, "admin001", "admin", ctx)
}
// makeUserRequest creates a request with regular user context
// Note: Using 12-char IDs to match CHAR(12) columns in schema (e.g., created_by)
func makeUserRequest(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder {
return makeRequestWithContext(handler, method, path, body, "user001", "verified_email")
func makeUserRequest(handler http.Handler, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder {
return makeRequestWithContext(handler, method, path, body, "user001", "verified_email", ctx)
}
// makeRequestWithContext creates a request with specific user context
func makeRequestWithContext(handler http.Handler, method, path string, body interface{}, userID, role string) *httptest.ResponseRecorder {
func makeRequestWithContext(handler http.Handler, method, path string, body interface{}, userID, role string, ctx context.Context) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
bodyBytes, _ := json.Marshal(body)
@@ -45,9 +45,13 @@ func makeRequestWithContext(handler http.Handler, method, path string, body inte
if id, paramName := extractIDFromPath(path); id != "" {
rctx.URLParams.Add(paramName, id)
}
// Extract request_id for: /api/admin/bookings/{id}/edit-requests/{request_id}/approve
if parts := strings.Split(path, "/"); len(parts) >= 8 && parts[5] == "edit-requests" {
rctx.URLParams.Add("request_id", parts[6])
}
}
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
ctx = context.WithValue(ctx, mw.UserIDKey, userID)
ctx = context.WithValue(ctx, mw.UserRoleKey, role)
req = req.WithContext(ctx)
+22 -25
View File
@@ -1,7 +1,6 @@
package auth
import (
"context"
"crussell/auth"
"crussell/db"
"github.com/jackc/pgx/v5"
@@ -29,9 +28,7 @@ import (
"golang.org/x/text/language"
)
var (
titleCaser = cases.Title(language.English)
)
const maxLoginInProgress = 20
@@ -173,8 +170,10 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
req.Phone = strings.TrimSpace(phone)
// Convert names to title case
req.FirstName = titleCaser.String(strings.ToLower(req.FirstName))
req.LastName = titleCaser.String(strings.ToLower(req.LastName))
// Create per-call caser (cases.Caser is not goroutine-safe)
tc := cases.Title(language.English)
req.FirstName = tc.String(strings.ToLower(req.FirstName))
req.LastName = tc.String(strings.ToLower(req.LastName))
// Parse date of birth
dob, err := time.Parse("2006-01-02", req.DateOfBirth)
@@ -203,7 +202,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Look up referrer by referral code
err := db.DB.QueryRow(r.Context(),
err := db.Conn.QueryRow(r.Context(),
"SELECT id FROM users WHERE referral_code = $1", req.ReferralCode).Scan(&referrerID)
if err != nil {
http.Error(w, "invalid referral code", http.StatusBadRequest)
@@ -218,7 +217,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
@@ -319,8 +318,8 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
var userID, passwordHash, role string
ctx := context.Background()
err := db.DB.QueryRow(ctx, `
ctx := r.Context()
err := db.Conn.QueryRow(ctx, `
SELECT id, password_hash, account_role
FROM users
WHERE email = $1 AND account_type = 'email'
@@ -334,7 +333,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
// Check if account is locked
var failedAttempts int
var lockedUntil *time.Time
err = db.DB.QueryRow(r.Context(), `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil)
err = db.Conn.QueryRow(r.Context(), `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil)
if err == nil && lockedUntil != nil && time.Now().Before(*lockedUntil) {
http.Error(w, "account is temporarily locked. try again later.", http.StatusTooManyRequests)
log.Printf("LOGIN_AUDIT: locked account attempt - user=%s ip=%s", userID, middleware.GetClientIP(r.Context()))
@@ -369,7 +368,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
// Increment failed attempts in DB with progressive lockout
var newFailed int
var newLockedUntil *time.Time
db.DB.QueryRow(r.Context(), `
db.Conn.QueryRow(r.Context(), `
UPDATE users
SET failed_attempts = failed_attempts + 1,
locked_until = CASE
@@ -395,7 +394,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
// On success, clear lockout and update last_login
// TODO: Password reset flow (MVP #4 in Future Work doc) must also clear
// failed_attempts and locked_until — a locked-out user can't call this handler.
db.DB.Exec(r.Context(), `UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = $1`, userID)
db.Conn.Exec(r.Context(), `UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = $1`, userID)
// Generate JWT
tokenString, jti, err := auth.GenerateToken(userID, role)
@@ -405,7 +404,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
}
// Generate refresh token for token rotation
refreshToken, err := auth.GenerateRefreshToken(userID, role)
refreshToken, err := auth.GenerateRefreshToken(r.Context(), userID, role)
if err != nil {
log.Printf("Failed to generate refresh token: %v", err)
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
@@ -428,7 +427,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
// Verify user still exists and role hasn't changed
var currentRole string
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT account_role FROM users WHERE id = $1
`, userID).Scan(&currentRole)
@@ -444,7 +443,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
// Revoke the old token's JTI before issuing a new one (rotation)
if oldJTI != "" {
auth.RevokeJTI(oldJTI, time.Now().Add(90*24*time.Hour)) // match refresh token lifetime
auth.RevokeJTI(r.Context(), oldJTI, time.Now().Add(90*24*time.Hour)) // match refresh token lifetime
}
// Generate new token
@@ -455,7 +454,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
}
// Also issue a refresh token (opaque, stored in DB)
refreshToken, err := auth.GenerateRefreshToken(userID, currentRole)
refreshToken, err := auth.GenerateRefreshToken(r.Context(), userID, currentRole)
if err != nil {
log.Printf("Failed to generate refresh token: %v", err)
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
@@ -479,7 +478,7 @@ func LogoutHandler(w http.ResponseWriter, r *http.Request) {
}
// Revoke the JTI — match the access token lifetime (1 hour)
auth.RevokeJTI(jti, time.Now().Add(1*time.Hour))
auth.RevokeJTI(r.Context(), jti, time.Now().Add(1*time.Hour))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"success": true})
@@ -516,7 +515,7 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
}
var userID string
err := db.DB.QueryRow(r.Context(),
err := db.Conn.QueryRow(r.Context(),
"SELECT id FROM users WHERE LOWER(email) = $1", email,
).Scan(&userID)
if err != nil {
@@ -533,7 +532,7 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
expiresAt := time.Now().Add(24 * time.Hour)
var code string
err = db.DB.QueryRow(r.Context(),
err = db.Conn.QueryRow(r.Context(),
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt,
).Scan(&code)
@@ -543,8 +542,6 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("DEBUG: Verification code for %s: %s (expires at %s)", email, code, expiresAt.Format(time.RFC3339))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Verification code generated"})
}
@@ -570,7 +567,7 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
var purpose string
var expiresAt time.Time
err := db.DB.QueryRow(r.Context(),
err := db.Conn.QueryRow(r.Context(),
`SELECT user_id, purpose, expires_at FROM verification_codes
WHERE code = $1 AND used_at IS NULL AND expires_at > NOW()`,
code,
@@ -579,7 +576,7 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, pgx.ErrNoRows) {
// Check if code exists but was already used or expired
var checkUsedAt *time.Time
checkErr := db.DB.QueryRow(r.Context(),
checkErr := db.Conn.QueryRow(r.Context(),
`SELECT used_at FROM verification_codes WHERE code = $1`, code,
).Scan(&checkUsedAt)
if checkErr != nil {
@@ -601,7 +598,7 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
+5 -5
View File
@@ -110,7 +110,7 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
weekday := int((localStart.Weekday() + 6) % 7)
var closeStr string
if err := db.DB.QueryRow(r.Context(), `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
if err := db.Conn.QueryRow(r.Context(), `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Not open on this day", http.StatusBadRequest)
return
@@ -129,7 +129,7 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
}
var cnt int
db.DB.QueryRow(r.Context(), `
db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed')
AND start_time < $2
AND start_time + (INTERVAL '1 minute' * (
@@ -155,7 +155,7 @@ func AdminReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -223,7 +223,7 @@ func calculateServiceDurationWithOverrides(ctx context.Context, serviceIDs []str
// If no overrides, use simple sum
if len(overrides) == 0 {
var duration int
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
SELECT COALESCE(SUM(dur), 0) FROM (
SELECT duration_minutes AS dur FROM services WHERE id = ANY($1)
UNION ALL
@@ -242,7 +242,7 @@ func calculateServiceDurationWithOverrides(ctx context.Context, serviceIDs []str
}
// Get all services
rows, err := db.DB.Query(ctx, `
rows, err := db.Conn.Query(ctx, `
SELECT id, duration_minutes FROM services WHERE id = ANY($1)
UNION ALL
SELECT id, duration_minutes FROM custom_services WHERE id = ANY($1)
+104 -100
View File
@@ -81,7 +81,7 @@ type BookingDiscount struct {
}
func fetchBookingDiscounts(ctx context.Context, bookingID string) ([]BookingDiscount, error) {
discountRows, err := db.DB.Query(ctx, `
discountRows, err := db.Conn.Query(ctx, `
SELECT
bd.id, bd.booking_id, bd.user_id, bd.discount_source, bd.source_id,
bd.campaign_type, bd.milestone_type, bd.discount_percent, bd.original_total, bd.discount_amount, bd.applied_at,
@@ -418,7 +418,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Count query uses the same WHERE but without complex SELECT subqueries,
// cursor, ORDER BY, or LIMIT — just a fast index scan on bookings.
var total int
if err := db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b"+whereClause, whereArgs...).Scan(&total); err != nil {
if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b"+whereClause, whereArgs...).Scan(&total); err != nil {
log.Printf("Failed to count bookings for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
@@ -473,7 +473,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
dataArgs = append(dataArgs, req.PerPage+1)
}
rows, err := db.DB.Query(r.Context(), dataQuery, dataArgs...)
rows, err := db.Conn.Query(r.Context(), dataQuery, dataArgs...)
if err != nil {
log.Printf("Failed to fetch bookings for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -515,7 +515,7 @@ func GetAllUserBookingsHandler(w http.ResponseWriter, r *http.Request) {
}
if len(bookingIDs) > 0 {
serviceRows, err := db.DB.Query(r.Context(), `
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT
bs.service_id, bs.override_price, bs.override_duration_minutes,
s.name, s.description, s.price, s.duration_minutes,
@@ -742,15 +742,9 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
args = append(args, req.PerPage+1)
}
rows, err := db.DB.Query(r.Context(), baseQuery, args...)
if err != nil {
log.Printf("Failed to fetch all bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
// Count query: simple SELECT COUNT(*) with same WHERE (no CTEs, joins, or subqueries).
// Run BEFORE the data query to avoid "conn busy" errors when routing
// through a per-test transaction (pgx.Tx does not support concurrent queries).
var total int
{
countWhere := ""
@@ -778,13 +772,21 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
et, _ := time.Parse("2006-01-02", *req.EndDate)
countArgs = append(countArgs, et.Add(23*time.Hour+59*time.Minute+59*time.Second))
}
if err := db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b"+countWhere, countArgs...).Scan(&total); err != nil {
if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b"+countWhere, countArgs...).Scan(&total); err != nil {
log.Printf("Failed to count admin bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
rows, err := db.Conn.Query(r.Context(), baseQuery, args...)
if err != nil {
log.Printf("Failed to fetch all bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var bookings []Booking
var bookingIDs []string
@@ -816,7 +818,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
}
if len(bookingIDs) > 0 {
serviceRows, err := db.DB.Query(r.Context(), `
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT bs.booking_id, s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
@@ -868,7 +870,7 @@ func GetAllAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
}
}
if len(userIDs) > 0 {
nhRows, err := db.DB.Query(r.Context(), `
nhRows, err := db.Conn.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
@@ -968,13 +970,13 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
// Count query: simple SELECT COUNT(*) (no cursor, since that filters rows).
var total int
if err := db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b WHERE b.user_id = $1", userID).Scan(&total); err != nil {
if err := db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM bookings b WHERE b.user_id = $1", userID).Scan(&total); err != nil {
log.Printf("Failed to count bookings for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
rows, err := db.DB.Query(r.Context(), baseQuery, args...)
rows, err := db.Conn.Query(r.Context(), baseQuery, args...)
if err != nil {
log.Printf("Failed to fetch bookings for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -1003,7 +1005,7 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
}
if len(bookingIDs) > 0 {
serviceRows, err := db.DB.Query(r.Context(), `
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT
bs.booking_id,
s.name,
@@ -1046,7 +1048,7 @@ func GetAllBookingsByUserHandler(w http.ResponseWriter, r *http.Request) {
}
serviceRows.Close()
paymentRows, err := db.DB.Query(r.Context(), `
paymentRows, err := db.Conn.Query(r.Context(), `
SELECT p.booking_id,
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed'), 0) AS amount_paid,
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.created_at < b.start_time), 0) AS pre_start_paid
@@ -1138,7 +1140,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
var depositRequired bool
var dateOfBirth sql.NullTime
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT
b.id, b.user_id, b.start_time, b.status, b.notes,
b.created_at, b.updated_at, b.created_by,
@@ -1174,7 +1176,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
}
var referralCodeUses int
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1
`, booking.User.ID).Scan(&referralCodeUses); err != nil {
log.Printf("Failed to fetch referral code uses for user %s: %v", booking.User.ID, err)
@@ -1182,7 +1184,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
booking.User.ReferralCodeUses = &referralCodeUses
var prevFirstName, prevLastName sql.NullString
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.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
@@ -1194,7 +1196,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
booking.User.PreviousLastName = &prevLastName.String
}
serviceRows, err := db.DB.Query(r.Context(), `
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT service_id, name, price, duration_minutes FROM (
SELECT
bs.service_id, s.name,
@@ -1242,7 +1244,7 @@ func GetAdminBookingHandler(w http.ResponseWriter, r *http.Request) {
}
booking.TotalAmount = totalAmount
paymentRows, err := db.DB.Query(r.Context(), `
paymentRows, err := db.Conn.Query(r.Context(), `
SELECT payment_type, payment_method, vendor_code, invoice_number,
status, amount, created_at
FROM payments
@@ -1355,7 +1357,7 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
var startTime time.Time
var currentStatus string
if err := db.DB.QueryRow(r.Context(), "SELECT start_time, status FROM bookings WHERE id = $1", bookingID).Scan(&startTime, &currentStatus); err != nil {
if err := db.Conn.QueryRow(r.Context(), "SELECT start_time, status FROM bookings WHERE id = $1", bookingID).Scan(&startTime, &currentStatus); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
@@ -1390,7 +1392,7 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
}
}
if len(serviceIDsNeedingLookup) > 0 {
rows, err := db.DB.Query(r.Context(), `SELECT id, duration_minutes FROM services WHERE id = ANY($1)`, serviceIDsNeedingLookup)
rows, err := db.Conn.Query(r.Context(), `SELECT id, duration_minutes FROM services WHERE id = ANY($1)`, serviceIDsNeedingLookup)
if err != nil {
log.Printf("Failed to batch fetch service durations: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -1433,7 +1435,7 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
newEndTime := startTime.Add(time.Duration(newTotalDuration) * time.Minute)
var nextBookingStart *time.Time
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT start_time FROM bookings
WHERE start_time > $1
AND status IN ('confirmed', 'pending', 'in_progress')
@@ -1451,7 +1453,7 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -1517,7 +1519,7 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
booking.User = &UserSummary{}
var depositRequired bool
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT
b.id, b.user_id, b.start_time, b.status, b.notes,
b.created_at, b.updated_at, b.created_by,
@@ -1542,14 +1544,14 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
}
var referralCodeUses int
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM user_referrals WHERE referrer_id = $1
`, booking.User.ID).Scan(&referralCodeUses); err != nil {
log.Printf("Failed to fetch referral code uses for user %s: %v", booking.User.ID, err)
}
booking.User.ReferralCodeUses = &referralCodeUses
serviceRows, err := db.DB.Query(r.Context(), `
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT
bs.service_id, bs.override_price, bs.override_duration_minutes,
s.name, s.description, s.price, s.duration_minutes
@@ -1624,7 +1626,7 @@ func UpdateBookingServicesHandler(w http.ResponseWriter, r *http.Request) {
}
booking.TotalAmount = totalAmount
paymentRows, err := db.DB.Query(r.Context(), `
paymentRows, err := db.Conn.Query(r.Context(), `
SELECT payment_type, payment_method, vendor_code, invoice_number,
status, amount, created_at
FROM payments
@@ -1795,30 +1797,12 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
searchQuery += fmt.Sprintf(" LIMIT $%d", paramCount)
args = append(args, perPage+1)
rows, err := db.DB.Query(r.Context(), searchQuery, args...)
rows, err := db.Conn.Query(r.Context(), searchQuery, args...)
if err != nil {
log.Printf("Failed to search bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
// Count query: SELECT COUNT(*) with the same search WHERE (no CTEs/joins/scoring).
var total int
{
countSQL := `SELECT COUNT(*) FROM bookings b LEFT JOIN users u ON b.user_id = u.id WHERE
b.id ILIKE $1 ESCAPE '\' OR b.notes ILIKE $1 ESCAPE '\' OR
b.status::text ILIKE $1 ESCAPE '\' OR u.n_first_name ILIKE $1 ESCAPE '\' OR
u.n_last_name ILIKE $1 ESCAPE '\' OR u.fn ILIKE $1 ESCAPE '\' OR
u.email ILIKE $1 ESCAPE '\' OR u.phone ILIKE $1 ESCAPE '\' OR
EXISTS (SELECT 1 FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id AND s.name ILIKE $1 ESCAPE '\') OR
EXISTS (SELECT 1 FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id AND cs.name ILIKE $1 ESCAPE '\')`
if err := db.DB.QueryRow(r.Context(), countSQL, searchPattern).Scan(&total); err != nil {
log.Printf("Failed to count search bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
var bookings []Booking
var bookingIDs []string
@@ -1846,9 +1830,27 @@ func SearchAdminBookingsHandler(w http.ResponseWriter, r *http.Request) {
bookings = append(bookings, b)
bookingIDs = append(bookingIDs, b.ID)
}
rows.Close()
// Count query runs AFTER the data query result set is consumed to avoid pgx "conn busy".
var total int
{
countSQL := `SELECT COUNT(*) FROM bookings b LEFT JOIN users u ON b.user_id = u.id WHERE
b.id ILIKE $1 ESCAPE '\' OR b.notes ILIKE $1 ESCAPE '\' OR
b.status::text ILIKE $1 ESCAPE '\' OR u.n_first_name ILIKE $1 ESCAPE '\' OR
u.n_last_name ILIKE $1 ESCAPE '\' OR u.fn ILIKE $1 ESCAPE '\' OR
u.email ILIKE $1 ESCAPE '\' OR u.phone ILIKE $1 ESCAPE '\' OR
EXISTS (SELECT 1 FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = b.id AND s.name ILIKE $1 ESCAPE '\') OR
EXISTS (SELECT 1 FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = b.id AND cs.name ILIKE $1 ESCAPE '\')`
if err := db.Conn.QueryRow(r.Context(), countSQL, searchPattern).Scan(&total); err != nil {
log.Printf("Failed to count search bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
if len(bookingIDs) > 0 {
serviceRows, err := db.DB.Query(r.Context(), `
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT booking_id, name FROM (
SELECT bs.booking_id, s.name
FROM booking_services bs
@@ -1935,7 +1937,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
if idempotencyKey != "" {
var existingBooking Booking
existingBooking.User = &UserSummary{}
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, b.deposit_required
FROM bookings b WHERE b.idempotency_key = $1
`, idempotencyKey).Scan(
@@ -1945,7 +1947,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
)
if err == nil {
// Booking already exists with this key — fetch services and return it
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes,
s.name, s.description, s.price, s.duration_minutes
FROM booking_services bs
@@ -1959,7 +1961,6 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
WHERE bcs.booking_id = $1
`, existingBooking.ID)
if err == nil {
defer rows.Close()
for rows.Next() {
var bs BookingService
if err := rows.Scan(
@@ -1970,11 +1971,12 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
}
existingBooking.Services = append(existingBooking.Services, bs)
}
rows.Close()
}
// Get payment info
var preStartPaid float64
db.DB.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingBooking.ID).Scan(&preStartPaid)
db.Conn.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingBooking.ID).Scan(&preStartPaid)
populateDepositFields(&existingBooking, existingBooking.DepositRequired, preStartPaid)
w.Header().Set("Content-Type", "application/json")
@@ -1995,7 +1997,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
}
var accountRole string
if err := db.DB.QueryRow(r.Context(), `SELECT account_role FROM users WHERE id = $1`, *req.UserID).Scan(&accountRole); err != nil {
if err := db.Conn.QueryRow(r.Context(), `SELECT account_role FROM users WHERE id = $1`, *req.UserID).Scan(&accountRole); err != nil {
http.Error(w, "User not found", http.StatusBadRequest)
return
}
@@ -2023,7 +2025,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
var depositsRequired int
if !isGuest {
if err := db.DB.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, userID).Scan(&depositsRequired); err != nil {
if err := db.Conn.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, userID).Scan(&depositsRequired); err != nil {
log.Printf("Failed to fetch deposits_required for user %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
@@ -2032,7 +2034,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
if !isGuest && depositsRequired > 0 {
var activeCount int
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings
WHERE user_id = $1 AND status IN ('pending', 'confirmed')
`, userID).Scan(&activeCount); err != nil {
@@ -2063,7 +2065,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
depositRequiredSnapshot := depositsRequired > 0
if !isGuest && len(req.ServiceIDs) > 0 {
patchTestRows, err := db.DB.Query(r.Context(), `
patchTestRows, err := db.Conn.Query(r.Context(), `
SELECT id, service_ids, notice_duration_hours, expiry_months
FROM patch_tests
WHERE service_ids::text[] && $1::text[]
@@ -2098,7 +2100,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
userPatchTests := make(map[string]time.Time)
if len(allPtIDs) > 0 {
uptRows, err := db.DB.Query(r.Context(), `
uptRows, err := db.Conn.Query(r.Context(), `
SELECT patch_test_id, tested_at
FROM user_patch_tests
WHERE user_id = $1 AND patch_test_id = ANY($2)
@@ -2155,7 +2157,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
}
var svcDuration int
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1)
`, req.ServiceIDs).Scan(&svcDuration); err != nil {
log.Printf("Failed to calc duration: %v", err)
@@ -2167,7 +2169,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
// DB uses 0=Monday..6=Sunday; Go uses 0=Sunday..6=Saturday. Convert.
weekday := int((localStart.Weekday() + 6) % 7)
var closeStr string
if err := db.DB.QueryRow(r.Context(), `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
if err := db.Conn.QueryRow(r.Context(), `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
log.Printf("Failed to get hours: %v", err)
http.Error(w, "Could not verify hours", http.StatusInternalServerError)
return
@@ -2181,7 +2183,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -2312,7 +2314,7 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
// Fetch services for the response
booking.Services = []BookingService{}
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes,
s.name, s.description, s.price, s.duration_minutes
FROM booking_services bs
@@ -2380,7 +2382,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
}
var currentStatus string
if err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&currentStatus); err != nil {
if err := db.Conn.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&currentStatus); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
return
@@ -2396,7 +2398,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
}
var durationMinutes int
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs
@@ -2415,7 +2417,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
var overlapCount int
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings
WHERE id != $1
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
@@ -2452,10 +2454,11 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
if daysToMonday == 0 {
daysToMonday = 7
}
weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
tm := req.StartTime.AddDate(0, 0, -daysToMonday+1)
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, tm.Location())
var isClosed bool
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT EXISTS (
SELECT 1 FROM exceptional_working_hours ewh
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
@@ -2475,7 +2478,7 @@ func EditBookingHandler(w http.ResponseWriter, r *http.Request) {
var booking Booking
booking.User = &UserSummary{}
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
UPDATE bookings
SET start_time = $1, updated_at = NOW()
WHERE id = $2 AND user_id = $3
@@ -2533,7 +2536,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
tx, txErr := db.DB.Begin(r.Context())
tx, txErr := db.Conn.Begin(r.Context())
if txErr != nil {
log.Printf("Failed to begin transaction for booking progress: %v", txErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -2725,7 +2728,7 @@ func ProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
_ = tx.QueryRow(r.Context(), `SELECT COUNT(*) FROM bookings WHERE status = 'completed'`).Scan(&globalCount)
var hasInPersonPayment bool
db.DB.QueryRow(r.Context(), `
db.Conn.QueryRow(r.Context(), `
SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method = 'in_person_card')`, bookingID).Scan(&hasInPersonPayment)
if hasInPersonPayment {
@@ -2913,12 +2916,12 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
var bkStart time.Time
var dur int
if err := db.DB.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&bkStart); err != nil {
if err := db.Conn.QueryRow(r.Context(), "SELECT start_time FROM bookings WHERE id = $1", bookingID).Scan(&bkStart); err != nil {
log.Printf("Failed to get start time: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
db.DB.QueryRow(r.Context(), `
db.Conn.QueryRow(r.Context(), `
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
@@ -2929,7 +2932,7 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
`, bookingID).Scan(&dur)
endTime := bkStart.Add(time.Duration(dur) * time.Minute)
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -3057,7 +3060,7 @@ func ConfirmBookingHandler(w http.ResponseWriter, r *http.Request) {
if dav.Service != nil {
var durationMinutes int
db.DB.QueryRow(r.Context(), `
db.Conn.QueryRow(r.Context(), `
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs
@@ -3103,7 +3106,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
}
var paymentExists bool
if err := db.DB.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)", bookingID).Scan(&paymentExists); err != nil {
if err := db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1)", bookingID).Scan(&paymentExists); err != nil {
log.Printf("Failed to check booking %s for user %s: %v", bookingID, userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
@@ -3131,7 +3134,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
// Get booking info needed for refund (before any transaction)
var originalStatus string
var startTime time.Time
if err := db.DB.QueryRow(r.Context(), "SELECT status, start_time FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus, &startTime); err != nil {
if err := db.Conn.QueryRow(r.Context(), "SELECT status, start_time FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(&originalStatus, &startTime); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found or access denied", http.StatusNotFound)
return
@@ -3163,7 +3166,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
}
// Refund succeeded (or no refund needed) — now cancel the booking
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -3241,7 +3244,7 @@ func DeleteBookingHandler(w http.ResponseWriter, r *http.Request) {
}
// Hard delete — no payments exist
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -3301,7 +3304,7 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
var createdBy sql.NullString
var depositRequired bool
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by,
b.deposit_required
FROM bookings b
@@ -3323,7 +3326,7 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
booking.CreatedBy = &createdBy.String
}
serviceRows, err := db.DB.Query(r.Context(), `
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT
bs.service_id, bs.override_price, bs.override_duration_minutes,
s.name, s.description, s.price, s.duration_minutes
@@ -3401,7 +3404,7 @@ func GetBookingHandler(w http.ResponseWriter, r *http.Request) {
booking.Services = append(booking.Services, s)
}
paymentRows, err := db.DB.Query(r.Context(), `
paymentRows, err := db.Conn.Query(r.Context(), `
SELECT
id, payment_type, payment_method, vendor_code, invoice_number,
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
@@ -3503,7 +3506,7 @@ func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) {
var startTime, createdAt, updatedAt time.Time
var durationMinutes int
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT id, user_id, start_time, status, COALESCE(notes, ''), COALESCE(created_by, ''), created_at, updated_at,
COALESCE((SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
@@ -3526,7 +3529,7 @@ func GetBookingCalendarHandler(w http.ResponseWriter, r *http.Request) {
return
}
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT s.name, COALESCE(bs.override_price, s.price)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
@@ -3649,7 +3652,7 @@ func GetOverlappingBookingsByTimeHandler(w http.ResponseWriter, r *http.Request)
return
}
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT
b.id,
b.start_time,
@@ -3698,7 +3701,7 @@ func GetOverlappingBookingsByTimeHandler(w http.ResponseWriter, r *http.Request)
continue
}
serviceRows, err := db.DB.Query(r.Context(), `
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT name FROM (
SELECT s.name
FROM booking_services bs
@@ -3747,7 +3750,7 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
// Get the booking's start time and duration
var startTime time.Time
var durationMinutes int
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT b.start_time,
(SELECT COALESCE(SUM(dur_val), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur_val
@@ -3771,7 +3774,7 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
endTime := startTime.Add(time.Duration(durationMinutes) * time.Minute)
// Find overlapping bookings (excluding the current booking and cancelled/completed ones)
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT
b.id,
b.start_time,
@@ -3820,7 +3823,7 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
}
// Get services for this booking
serviceRows, err := db.DB.Query(r.Context(), `
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT name FROM (
SELECT s.name
FROM booking_services bs
@@ -3879,7 +3882,7 @@ func GetBookingsByDateRangeHandler(w http.ResponseWriter, r *http.Request) {
endOfDay := endTime.Add(24 * time.Hour)
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT
b.id,
b.start_time,
@@ -3922,7 +3925,7 @@ func GetBookingsByDateRangeHandler(w http.ResponseWriter, r *http.Request) {
continue
}
serviceRows, err := db.DB.Query(r.Context(), `
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT name FROM (
SELECT s.name
FROM booking_services bs
@@ -3986,7 +3989,7 @@ func GetBookingsByCreatedRangeHandler(w http.ResponseWriter, r *http.Request) {
}
}
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT
b.id,
b.start_time,
@@ -4028,7 +4031,7 @@ func GetBookingsByCreatedRangeHandler(w http.ResponseWriter, r *http.Request) {
continue
}
serviceRows, err := db.DB.Query(r.Context(), `
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT name FROM (
SELECT s.name
FROM booking_services bs
@@ -4106,7 +4109,7 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
var currentStatus string
var bookingUserID string
var startTime time.Time
if err := db.DB.QueryRow(r.Context(), "SELECT status, user_id, start_time FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus, &bookingUserID, &startTime); err != nil {
if err := db.Conn.QueryRow(r.Context(), "SELECT status, user_id, start_time FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus, &bookingUserID, &startTime); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
@@ -4122,7 +4125,7 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
}
// All write operations wrapped in a transaction for atomicity.
tx, txErr := db.DB.Begin(r.Context())
tx, txErr := db.Conn.Begin(r.Context())
if txErr != nil {
log.Printf("Failed to begin transaction for reschedule: %v", txErr)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -4147,7 +4150,7 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
}
var durationMinutes int
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs
@@ -4180,10 +4183,11 @@ func AdminRescheduleBookingHandler(w http.ResponseWriter, r *http.Request) {
if daysToMonday == 0 {
daysToMonday = 7
}
weekStart := req.StartTime.AddDate(0, 0, -daysToMonday+1).Truncate(24 * time.Hour)
tm := req.StartTime.AddDate(0, 0, -daysToMonday+1)
weekStart := time.Date(tm.Year(), tm.Month(), tm.Day(), 0, 0, 0, 0, tm.Location())
var isClosed bool
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT EXISTS (
SELECT 1 FROM exceptional_working_hours ewh
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
+58 -48
View File
@@ -36,7 +36,7 @@ func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -175,7 +175,7 @@ func AdminCancelBookingHandler(w http.ResponseWriter, r *http.Request) {
}
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -293,7 +293,7 @@ func AdminGetInProgressBookingHandler(w http.ResponseWriter, r *http.Request) {
var b Booking
var userID, fullName string
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT
b.id,
b.start_time,
@@ -368,7 +368,7 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
// Check if booking exists and is not completed/cancelled
var currentStatus string
err := db.DB.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus)
err := db.Conn.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
@@ -387,7 +387,7 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
// Get booking duration for overlap check
var durationMinutes int
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT COALESCE(SUM(dur), 60) FROM (
SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur
FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = $1
@@ -404,7 +404,7 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
// Check for overlapping bookings (excluding the current booking)
var overlapCount int
newEndTime := req.StartTime.Add(time.Duration(durationMinutes) * time.Minute)
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings
WHERE id != $1
AND status NOT IN ('completed', 'client_cancelled', 'we_cancelled', 'no_show', 'deposit_lapsed')
@@ -439,7 +439,7 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
// Check if salon is closed (exceptional hours)
var isClosed bool
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT EXISTS (
SELECT 1 FROM exceptional_working_hours ewh
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
@@ -478,7 +478,7 @@ func AdminEditBookingHandler(w http.ResponseWriter, r *http.Request) {
}
// Perform the update
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -569,12 +569,12 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
// If idempotency key provided, check for existing booking
if idempotencyKey != "" {
var existingID string
err := db.DB.QueryRow(r.Context(), `SELECT id FROM bookings WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID)
err := db.Conn.QueryRow(r.Context(), `SELECT id FROM bookings WHERE idempotency_key = $1`, idempotencyKey).Scan(&existingID)
if err == nil {
// Booking already exists with this key — fetch and return it
var existingBooking Booking
existingBooking.User = &UserSummary{}
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT b.id, b.user_id, b.start_time, b.status, b.notes, b.created_at, b.updated_at, b.created_by, b.deposit_required
FROM bookings b WHERE b.id = $1
`, existingID).Scan(
@@ -584,7 +584,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
)
if err == nil {
// Fetch services for the response
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT bs.booking_id, bs.service_id, bs.override_price, bs.override_duration_minutes,
s.name, s.description, s.price, s.duration_minutes
FROM booking_services bs
@@ -614,8 +614,8 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
// Get deposit info
var depositRequired bool
var preStartPaid float64
db.DB.QueryRow(r.Context(), `SELECT deposit_required FROM bookings WHERE id = $1`, existingID).Scan(&depositRequired)
db.DB.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid)
db.Conn.QueryRow(r.Context(), `SELECT deposit_required FROM bookings WHERE id = $1`, existingID).Scan(&depositRequired)
db.Conn.QueryRow(r.Context(), `SELECT COALESCE(SUM(amount), 0) FROM payments WHERE booking_id = $1 AND payment_type IN ('deposit', 'full') AND status = 'completed'`, existingID).Scan(&preStartPaid)
populateDepositFields(&existingBooking, depositRequired, preStartPaid)
w.Header().Set("Content-Type", "application/json")
@@ -642,7 +642,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
// Check patch test requirements for all regular services (custom services skip patch tests)
if len(req.ServiceIDs) > 0 {
patchTestRows, err := db.DB.Query(r.Context(), `
patchTestRows, err := db.Conn.Query(r.Context(), `
SELECT id, service_ids, notice_duration_hours, expiry_months
FROM patch_tests
WHERE service_ids::text[] && $1::text[]
@@ -677,7 +677,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
userPatchTests := make(map[string]time.Time)
if len(allPtIDs) > 0 {
uptRows, err := db.DB.Query(r.Context(), `
uptRows, err := db.Conn.Query(r.Context(), `
SELECT patch_test_id, tested_at
FROM user_patch_tests
WHERE user_id = $1 AND patch_test_id = ANY($2)
@@ -737,7 +737,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
if enforceDeposits {
// Read live deposits_required from user
var depositsRequired int
if err := db.DB.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, req.UserID).Scan(&depositsRequired); err != nil {
if err := db.Conn.QueryRow(r.Context(), `SELECT deposits_required FROM users WHERE id = $1`, req.UserID).Scan(&depositsRequired); err != nil {
log.Printf("Failed to fetch deposits_required for user %s: %v", req.UserID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
@@ -746,7 +746,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
// Check one-active-booking limit when deposits are outstanding
if depositsRequired > 0 {
var activeCount int
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings
WHERE user_id = $1 AND status IN ('pending', 'confirmed')
`, req.UserID).Scan(&activeCount); err != nil {
@@ -805,7 +805,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
// Check if there's an exceptional hours entry that makes this time unavailable
var isClosed bool
var checkErr error
checkErr = db.DB.QueryRow(r.Context(), `
checkErr = db.Conn.QueryRow(r.Context(), `
SELECT EXISTS (
SELECT 1 FROM exceptional_working_hours ewh
JOIN exceptional_group_applications ega ON ewh.group_id = ega.group_id
@@ -830,7 +830,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
// Check for overlapping confirmed/in_progress/completed bookings
allIDs := append(req.ServiceIDs, req.CustomServiceIDs...)
var dur int
db.DB.QueryRow(r.Context(), `
db.Conn.QueryRow(r.Context(), `
SELECT COALESCE(SUM(dur), 0) FROM (
SELECT duration_minutes AS dur FROM services WHERE id = ANY($1)
UNION ALL
@@ -840,7 +840,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
newEnd := req.StartTime.Add(time.Duration(dur) * time.Minute)
var cnt int
db.DB.QueryRow(r.Context(), `
db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed') AND start_time < $2 AND start_time + (INTERVAL '1 minute' * (SELECT COALESCE(SUM(dur),60) FROM (SELECT COALESCE(bs.override_duration_minutes,s.duration_minutes) AS dur FROM booking_services bs JOIN services s ON bs.service_id=s.id WHERE bs.booking_id=bookings.id UNION ALL SELECT COALESCE(bcs.override_duration_minutes,cs.duration_minutes) FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id=cs.id WHERE bcs.booking_id=bookings.id) sub)) > $1
`, req.StartTime, newEnd).Scan(&cnt)
if cnt > 0 {
@@ -854,7 +854,7 @@ func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
log.Printf("Failed to check time blocker overlap: %v", err)
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -1145,7 +1145,7 @@ type EnrichedEditRequest struct {
func buildEnrichedEditRequest(ctx context.Context, editReq *BookingEditRequest) (*EnrichedEditRequest, error) {
var bStartTime time.Time
var bNotes *string
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
SELECT start_time, notes FROM bookings WHERE id = $1
`, editReq.BookingID).Scan(&bStartTime, &bNotes)
if err != nil {
@@ -1226,7 +1226,7 @@ func buildEnrichedEditRequest(ctx context.Context, editReq *BookingEditRequest)
// queryBookingServicesWithDetails returns service details for a booking, respecting overrides.
func queryBookingServicesWithDetails(ctx context.Context, bookingID string) ([]EditServiceDetail, error) {
rows, err := db.DB.Query(ctx, `
rows, err := db.Conn.Query(ctx, `
SELECT s.id, s.name,
COALESCE(bs.override_price, s.price) as price,
COALESCE(bs.override_duration_minutes, s.duration_minutes) as duration_minutes
@@ -1267,7 +1267,7 @@ func queryServiceDetailsByIDs(ctx context.Context, serviceIDs []string) ([]EditS
return []EditServiceDetail{}, nil
}
rows, err := db.DB.Query(ctx, `
rows, err := db.Conn.Query(ctx, `
SELECT id, name, price, duration_minutes
FROM services
WHERE id = ANY($1)
@@ -1308,7 +1308,7 @@ func sumServiceDurations(services []EditServiceDetail) int {
func queryUserSummary(ctx context.Context, userID string) (*EditUserSummary, error) {
var summary EditUserSummary
var prevFirstName, prevLastName sql.NullString
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
SELECT u.id, u.fn, u.email, u.phone,
nh.previous_first_name, nh.previous_last_name
FROM users u
@@ -1350,7 +1350,7 @@ func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
// 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)
err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
@@ -1367,7 +1367,7 @@ func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
}
// Use transaction to delete edit request and associated admin notification
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -1459,7 +1459,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
// 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)
err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
@@ -1479,7 +1479,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
var currentStatus string
var currentStartTime time.Time
var depositRequired bool
err = db.DB.QueryRow(r.Context(), "SELECT status, start_time, deposit_required FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus, &currentStartTime, &depositRequired)
err = db.Conn.QueryRow(r.Context(), "SELECT status, start_time, deposit_required FROM bookings WHERE id = $1", bookingID).Scan(&currentStatus, &currentStartTime, &depositRequired)
if err != nil {
log.Printf("Failed to get booking status %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -1494,11 +1494,11 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
// Query payment and timing info (used for validation AND auto-approval later)
var hasPayments bool
hoursUntilCurrent := currentStartTime.Sub(time.Now()).Hours()
db.DB.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND status = 'completed')", bookingID).Scan(&hasPayments)
db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND status = 'completed')", bookingID).Scan(&hasPayments)
// Check if booking has discounts (affects auto-approval decisions)
var hasDiscounts bool
db.DB.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1)", bookingID).Scan(&hasDiscounts)
db.Conn.QueryRow(r.Context(), "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1)", bookingID).Scan(&hasDiscounts)
if req.NewStartTime != nil && !req.NewStartTime.Equal(currentStartTime) {
if hasPayments && hoursUntilCurrent < 72 {
@@ -1519,7 +1519,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
}
if len(req.NewServices) > 0 {
var overrideCount int
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM (
SELECT 1 FROM booking_services
WHERE booking_id = $1 AND (override_price IS NOT NULL OR override_duration_minutes IS NOT NULL)
@@ -1539,7 +1539,7 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
}
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -1808,9 +1808,9 @@ func AdminListEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
var total int
// Count query (no ORDER BY needed).
db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM booking_edit_requests").Scan(&total)
db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM booking_edit_requests").Scan(&total)
rows, err := db.DB.Query(r.Context(), baseQuery, args...)
rows, err := db.Conn.Query(r.Context(), baseQuery, args...)
if err != nil {
log.Printf("Failed to fetch edit requests: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -1878,7 +1878,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -1915,7 +1915,7 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
// Check for applied discounts (admin warning only — discounts remain locked in)
var discountCount int
db.DB.QueryRow(r.Context(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount)
db.Conn.QueryRow(r.Context(), `SELECT COUNT(*) FROM booking_discounts WHERE booking_id = $1`, bookingID).Scan(&discountCount)
if discountCount > 0 {
log.Printf("ADMIN APPROVE EDIT: Booking %s has %d discount(s) applied — discounts remain locked in after reschedule", bookingID, discountCount)
}
@@ -2138,7 +2138,7 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
// First get the booking_id from the edit request before deleting
var bookingID string
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT booking_id FROM booking_edit_requests WHERE id = $1
`, requestID).Scan(&bookingID)
if err != nil {
@@ -2152,7 +2152,7 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
}
// Use transaction to delete edit request and acknowledge associated admin notification
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -2217,7 +2217,7 @@ func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) {
}
var ownerID string
err := db.DB.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
err := db.Conn.QueryRow(r.Context(), "SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&ownerID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
@@ -2235,7 +2235,7 @@ func GetMyEditRequestHandler(w http.ResponseWriter, r *http.Request) {
var editReq BookingEditRequest
var newServices []string
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
FROM booking_edit_requests
WHERE booking_id = $1 AND requested_by = $2
@@ -2286,7 +2286,7 @@ func GetMyEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
return
}
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
FROM booking_edit_requests
WHERE requested_by = $1
@@ -2299,7 +2299,8 @@ func GetMyEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
}
defer rows.Close()
var enrichedRequests []*EnrichedEditRequest
// Collect all edit requests first to avoid nested queries on the same tx connection.
var editReqs []BookingEditRequest
for rows.Next() {
var editReq BookingEditRequest
var newServices []string
@@ -2317,7 +2318,11 @@ func GetMyEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
continue
}
editReq.NewServices = newServices
editReqs = append(editReqs, editReq)
}
var enrichedRequests []*EnrichedEditRequest
for _, editReq := range editReqs {
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
if err != nil {
log.Printf("Failed to build enriched edit request for %s: %v", editReq.ID, err)
@@ -2339,7 +2344,7 @@ func GetMyEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
// AdminListAllEditRequestsHandler returns ALL pending edit requests across all bookings.
// GET /api/admin/bookings/edit-requests
func AdminListAllEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
FROM booking_edit_requests
ORDER BY updated_at DESC
@@ -2351,7 +2356,8 @@ func AdminListAllEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
}
defer rows.Close()
var enrichedRequests []*EnrichedEditRequest
// Collect all edit requests first to avoid nested queries on the same tx connection.
var editReqs []BookingEditRequest
for rows.Next() {
var editReq BookingEditRequest
var newServices []string
@@ -2369,7 +2375,11 @@ func AdminListAllEditRequestsHandler(w http.ResponseWriter, r *http.Request) {
continue
}
editReq.NewServices = newServices
editReqs = append(editReqs, editReq)
}
var enrichedRequests []*EnrichedEditRequest
for _, editReq := range editReqs {
enriched, err := buildEnrichedEditRequest(r.Context(), &editReq)
if err != nil {
log.Printf("Failed to build enriched edit request for %s: %v", editReq.ID, err)
@@ -2399,7 +2409,7 @@ func AdminGetBookingEditRequestHandler(w http.ResponseWriter, r *http.Request) {
var editReq BookingEditRequest
var newServices []string
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT id, booking_id, requested_by, new_start_time, new_services, notes, has_overrides, updated_at
FROM booking_edit_requests
WHERE booking_id = $1
@@ -2445,7 +2455,7 @@ func AdminGetBookingEditRequestHandler(w http.ResponseWriter, r *http.Request) {
// that have not been forgiven (not in forgiven_no_shows table)
func CountUnforgivenNoShows(ctx context.Context, userID string) (int, error) {
var count int
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
SELECT COUNT(*)
FROM bookings b
WHERE b.user_id = $1
@@ -2467,7 +2477,7 @@ func ApplyDepositsIfNeeded(ctx context.Context, userID string) (bool, error) {
}
if count >= 2 {
// Apply 3 deposits
_, err := db.DB.Exec(ctx, `
_, err := db.Conn.Exec(ctx, `
UPDATE users SET deposits_required = 3 WHERE id = $1
`, userID)
if err != nil {
+6 -6
View File
@@ -89,7 +89,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
// d. Calculate total duration: SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1)
var svcDuration int
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT COALESCE(SUM(duration_minutes), 0) FROM services WHERE id = ANY($1)
`, req.ServiceIDs).Scan(&svcDuration); err != nil {
log.Printf("Failed to calculate duration: %v", err)
@@ -113,7 +113,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
localStart := req.StartTime.In(londonLocation)
weekday := int((localStart.Weekday() + 6) % 7)
var closeStr string
if err := db.DB.QueryRow(r.Context(), `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
if err := db.Conn.QueryRow(r.Context(), `SELECT end_time::text FROM working_hours WHERE weekday = $1`, weekday).Scan(&closeStr); err != nil {
log.Printf("Failed to get hours: %v", err)
http.Error(w, "Could not verify hours", http.StatusInternalServerError)
return
@@ -129,7 +129,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
// g. Check existing booking overlap (same query as CreateBookingHandler)
endTime := req.StartTime.Add(time.Duration(svcDuration) * time.Minute)
var cnt int
db.DB.QueryRow(r.Context(), `
db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM bookings WHERE status IN ('pending','confirmed','in_progress','completed')
AND start_time < $2
AND start_time + (INTERVAL '1 minute' * (
@@ -161,7 +161,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
var createdAt time.Time
if hasAuth {
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -202,7 +202,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
// ANONYMOUS: Check cap (50 in 10 minutes)
tenMinutesAgo := time.Now().Add(-10 * time.Minute)
var anonCount int
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM time_blockers
WHERE description LIKE 'RESERVATION:anon:%' AND created_at > $1
`, tenMinutesAgo).Scan(&anonCount); err != nil {
@@ -221,7 +221,7 @@ func ReserveSlotHandler(w http.ResponseWriter, r *http.Request) {
description := fmt.Sprintf("RESERVATION:anon:%s:%d", ipHash, time.Now().UnixNano())
// Insert new reservation with created_by = NULL
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, $2, $3, NULL)
RETURNING id, created_at
+10 -10
View File
@@ -120,8 +120,15 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
baseQuery += fmt.Sprintf(" LIMIT $%d", param)
args = append(args, perPage+1)
var total int
countWhere := ""
if !includeAcknowledged {
countWhere += " WHERE an.acknowledged_at IS NULL"
}
db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM admin_notifications an"+countWhere).Scan(&total)
// Query
rows, err := db.DB.Query(r.Context(), baseQuery, args...)
rows, err := db.Conn.Query(r.Context(), baseQuery, args...)
if err != nil {
log.Printf("Failed to fetch notifications: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -129,13 +136,6 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
}
defer rows.Close()
var total int
countWhere := ""
if !includeAcknowledged {
countWhere += " WHERE an.acknowledged_at IS NULL"
}
db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM admin_notifications an"+countWhere).Scan(&total)
var notifications []AdminNotification
for rows.Next() {
@@ -208,7 +208,7 @@ func GetNotifications(w http.ResponseWriter, r *http.Request) {
// Returns the count of unacknowledged notifications for the bell icon.
func GetUnreadCount(w http.ResponseWriter, r *http.Request) {
var count int
err := db.DB.QueryRow(r.Context(),
err := db.Conn.QueryRow(r.Context(),
`SELECT COUNT(*) FROM admin_notifications WHERE acknowledged_at IS NULL`,
).Scan(&count)
if err != nil {
@@ -238,7 +238,7 @@ func AcknowledgeNotification(w http.ResponseWriter, r *http.Request) {
WHERE id = $1 AND acknowledged_at IS NULL
`
cmdTag, err := db.DB.Exec(r.Context(), query, idStr)
cmdTag, err := db.Conn.Exec(r.Context(), query, idStr)
if err != nil {
log.Printf("Failed to acknowledge notification %s: %v", idStr, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
+29 -26
View File
@@ -119,7 +119,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
// --- Aggregate totals (unfiltered, unpaginated) ---
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
SELECT COALESCE(SUM(amount_remaining), 0)
FROM gift_cards
WHERE redeemed_by IS NULL
@@ -130,7 +130,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
return
}
err = db.DB.QueryRow(ctx, `
err = db.Conn.QueryRow(ctx, `
SELECT COALESCE(SUM(balance), 0)
FROM user_giftcard_balances
`).Scan(&resp.TotalUserBalances)
@@ -196,26 +196,29 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
gcListArgs = append(gcListArgs, perPage+1)
}
gcRows, err := db.DB.Query(ctx, gcListQuery, gcListArgs...)
if err != nil {
log.Printf("Failed to query gift cards: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer gcRows.Close()
// Count query: total matching gift cards (same WHERE, without cursor/ORDER BY/LIMIT).
// Run BEFORE the data query to avoid "conn busy" when using a per-test
// transaction (single connection).
gcTotal = 0
if whereSQL != "" {
countArgs := []interface{}{}
if searchTerm != "" {
countArgs = append(countArgs, "%"+searchTerm+"%")
}
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards "+whereSQL, countArgs...).Scan(&gcTotal)
db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards "+whereSQL, countArgs...).Scan(&gcTotal)
} else {
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards").Scan(&gcTotal)
db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards").Scan(&gcTotal)
}
gcRows, err := db.Conn.Query(ctx, gcListQuery, gcListArgs...)
if err != nil {
log.Printf("Failed to query gift cards: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
defer gcRows.Close()
for gcRows.Next() {
var gc GiftCard
@@ -266,7 +269,7 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
ubListArgs = []interface{}{}
}
ubRows, err := db.DB.Query(ctx, ubListQuery, ubListArgs...)
ubRows, err := db.Conn.Query(ctx, ubListQuery, ubListArgs...)
if err != nil {
log.Printf("Failed to query user balances: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -276,11 +279,11 @@ func GetGiftCards(w http.ResponseWriter, r *http.Request) {
// Count query for user balances.
if searchTerm != "" {
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances b
db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_giftcard_balances b
JOIN users u ON b.user_id = u.id
WHERE u.n_first_name ILIKE $1 OR u.n_last_name ILIKE $1 OR u.email ILIKE $1`, "%"+searchTerm+"%").Scan(&ubTotal)
} else {
db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances").Scan(&ubTotal)
db.Conn.QueryRow(ctx, "SELECT COUNT(*) FROM user_giftcard_balances").Scan(&ubTotal)
}
for ubRows.Next() {
@@ -341,7 +344,7 @@ func CreateGiftCard(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(ctx)
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -435,7 +438,7 @@ func TopUpGiftCard(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(ctx)
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -546,7 +549,7 @@ func TransferGiftCard(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(ctx)
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -646,7 +649,7 @@ func RedeemGiftCard(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(ctx)
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -740,7 +743,7 @@ func GetGiftCardBalance(w http.ResponseWriter, r *http.Request) {
}
var balance float64
err := db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
err := db.Conn.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
w.Header().Set("Content-Type", "application/json")
@@ -768,7 +771,7 @@ func GetUserGiftCardBalanceAdmin(w http.ResponseWriter, r *http.Request) {
adminID, _ := ctx.Value(mw.UserIDKey).(string)
var balance float64
err := db.DB.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
err := db.Conn.QueryRow(ctx, "SELECT balance FROM user_giftcard_balances WHERE user_id = $1", userID).Scan(&balance)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
w.Header().Set("Content-Type", "application/json")
@@ -781,7 +784,7 @@ func GetUserGiftCardBalanceAdmin(w http.ResponseWriter, r *http.Request) {
}
detailsJSON := fmt.Sprintf(`{"balance": %.2f}`, balance)
if _, err := db.DB.Exec(ctx, `
if _, err := db.Conn.Exec(ctx, `
INSERT INTO admin_audit_log (admin_id, action_type, target_user_id, details)
VALUES ($1, 'balance_check', $2, $3::jsonb)
`, adminID, userID, detailsJSON); err != nil {
@@ -887,7 +890,7 @@ func BuyGiftCard(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(ctx)
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -1019,7 +1022,7 @@ type ExpiredBalance struct {
func GetExpiredBalances(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rows, err := db.DB.Query(ctx, `
rows, err := db.Conn.Query(ctx, `
SELECT id, account_id, original_balance, expired_at, claimed_at, claimed_by_admin, notes
FROM gift_card_expired_balances
ORDER BY expired_at DESC
@@ -1096,7 +1099,7 @@ func ClaimExpiredBalance(w http.ResponseWriter, r *http.Request) {
}
var existingClaimedAt sql.NullTime
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
SELECT claimed_at FROM gift_card_expired_balances WHERE id = $1
`, req.BalanceID).Scan(&existingClaimedAt)
if err != nil {
@@ -1114,7 +1117,7 @@ func ClaimExpiredBalance(w http.ResponseWriter, r *http.Request) {
return
}
_, err = db.DB.Exec(ctx, `
_, err = db.Conn.Exec(ctx, `
UPDATE gift_card_expired_balances
SET claimed_at = NOW(), claimed_by_admin = $1, notes = $2
WHERE id = $3
+24 -24
View File
@@ -138,7 +138,7 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri
}
var bookingTotal float64
db.DB.QueryRow(ctx, `
db.Conn.QueryRow(ctx, `
SELECT COALESCE(SUM(price_val), 0) FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs
@@ -162,7 +162,7 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri
var campaignID string
var campaignPercent float64
var campaignName string
if err := db.DB.QueryRow(ctx, `
if err := db.Conn.QueryRow(ctx, `
SELECT id, discount_percent, name FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'time_based'
AND start_date <= NOW() AND end_date >= NOW()
@@ -170,7 +170,7 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri
ORDER BY discount_percent DESC LIMIT 1
`).Scan(&campaignID, &campaignPercent, &campaignName); err == nil && campaignID != "" {
var exists int
db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, campaignID).Scan(&exists)
db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, campaignID).Scan(&exists)
if exists == 0 {
amount := roundTo2(bookingTotal * campaignPercent / 100)
resp.Discounts = append(resp.Discounts, DiscountPreview{
@@ -184,12 +184,12 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri
}
var userBookingCount int
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&userBookingCount)
db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&userBookingCount)
var milestoneCampaignID string
var milestonePercent float64
var milestoneName string
db.DB.QueryRow(ctx, `
db.Conn.QueryRow(ctx, `
SELECT id, discount_percent, name FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'per_user_booking_count'
AND milestone_value = $1
@@ -198,7 +198,7 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri
if milestoneCampaignID != "" {
var exists int
db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, milestoneCampaignID).Scan(&exists)
db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, milestoneCampaignID).Scan(&exists)
if exists == 0 {
amount := roundTo2(bookingTotal * milestonePercent / 100)
resp.Discounts = append(resp.Discounts, DiscountPreview{
@@ -212,7 +212,7 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri
}
var firstVisitDate time.Time
db.DB.QueryRow(ctx, `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&firstVisitDate)
db.Conn.QueryRow(ctx, `SELECT MIN(start_time) FROM bookings WHERE user_id = $1 AND status = 'completed'`, userID).Scan(&firstVisitDate)
if !firstVisitDate.IsZero() {
type annCamp struct {
id string
@@ -221,7 +221,7 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri
unit string
name string
}
annRows, err := db.DB.Query(ctx, `
annRows, err := db.Conn.Query(ctx, `
SELECT id, discount_percent, milestone_value, milestone_unit, name FROM discount_campaigns
WHERE status = 'active' AND campaign_type = 'milestone' AND milestone_type = 'anniversary'
AND NOT EXISTS (SELECT 1 FROM booking_discounts WHERE user_id = $1 AND source_id = discount_campaigns.id AND milestone_type = 'anniversary')
@@ -238,7 +238,7 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri
for _, c := range campaigns {
var exists int
db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, c.id).Scan(&exists)
db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND source_id = $2`, bookingID, c.id).Scan(&exists)
if exists > 0 {
continue
}
@@ -268,13 +268,13 @@ func calculateDiscountPreview(ctx context.Context, bookingID string, userID stri
// Check for referrer's unused referral discount
var rdID string
var rdPercent float64
if err := db.DB.QueryRow(ctx, `
if err := db.Conn.QueryRow(ctx, `
SELECT id, discount_percent FROM referral_discounts
WHERE user_id = $1 AND used = FALSE
LIMIT 1
`, userID).Scan(&rdID, &rdPercent); err == nil && rdID != "" {
exists := 0
db.DB.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral' AND source_id = $2`, bookingID, rdID).Scan(&exists)
db.Conn.QueryRow(ctx, `SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'referral' AND source_id = $2`, bookingID, rdID).Scan(&exists)
if exists == 0 {
amount := roundTo2(bookingTotal * rdPercent / 100)
resp.Discounts = append(resp.Discounts, DiscountPreview{
@@ -370,7 +370,7 @@ func CreateTerminalPayment(w http.ResponseWriter, r *http.Request) {
// Route based on payment method
if req.PaymentMethod != nil && (*req.PaymentMethod == "cash" || *req.PaymentMethod == "giftcard") {
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -700,10 +700,10 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
//
// We acquire a dedicated connection from the pool and hold it for the
// duration of the handler so that lock and unlock use the same connection.
// Using db.DB.Exec() for both would be unsafe — each call may get a
// Using db.Conn.Exec() for both would be unsafe — each call may get a
// different pool connection, and pg_advisory_unlock on a different session
// is a silent no-op, leaking the lock.
pinConn, err := db.DB.Acquire(r.Context())
pinConn, err := db.Conn.Acquire(r.Context())
if err != nil {
log.Printf("Failed to acquire connection for payment lock: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -772,7 +772,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// concurrent payments of the same type.
if req.PaymentType != "partial" {
var existingCount int
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*) FROM payments
WHERE booking_id = $1
AND status = 'completed'
@@ -887,7 +887,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// so that if any insert fails the entire group rolls back. This prevents
// a data inconsistency where Square charged the customer but only part of
// the split is reflected in the DB.
tx, txErr := db.DB.Begin(r.Context())
tx, txErr := db.Conn.Begin(r.Context())
if txErr != nil {
log.Printf("Failed to begin transaction for payment records: %v", txErr)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -972,7 +972,7 @@ func CreateBookingPayment(w http.ResponseWriter, r *http.Request) {
// would create a credit balance or require a refund.
func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, userID string) {
var existingPayment int
db.DB.QueryRow(ctx, `
db.Conn.QueryRow(ctx, `
SELECT COUNT(*) FROM payments
WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')
`, bookingID).Scan(&existingPayment)
@@ -987,7 +987,7 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user
}
var bookingTotal float64
if err := db.DB.QueryRow(ctx, `
if err := db.Conn.QueryRow(ctx, `
SELECT COALESCE(SUM(price_val), 0) FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs
@@ -1007,7 +1007,7 @@ func applyEligibleCampaignsAtPayment(ctx context.Context, bookingID string, user
return
}
tx, err := db.DB.Begin(ctx)
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin discount application transaction: %v", err)
return
@@ -1768,7 +1768,7 @@ func AcquirePaymentLock(w http.ResponseWriter, r *http.Request) {
// Verify the user owns this booking.
var bookingUserID string
if err := db.DB.QueryRow(r.Context(),
if err := db.Conn.QueryRow(r.Context(),
"SELECT user_id FROM bookings WHERE id = $1", bookingID,
).Scan(&bookingUserID); err != nil {
http.Error(w, "Booking not found", http.StatusNotFound)
@@ -1786,7 +1786,7 @@ func AcquirePaymentLock(w http.ResponseWriter, r *http.Request) {
// handles the sub-5-minute race, this catches the >5-minute gap.
var currentStatus string
var startTime time.Time
if err := db.DB.QueryRow(r.Context(),
if err := db.Conn.QueryRow(r.Context(),
"SELECT status, start_time FROM bookings WHERE id = $1", bookingID,
).Scan(&currentStatus, &startTime); err != nil {
http.Error(w, "Booking not found", http.StatusNotFound)
@@ -1803,14 +1803,14 @@ func AcquirePaymentLock(w http.ResponseWriter, r *http.Request) {
// Upsert the time_blocker: delete any existing PAYMENT_IN_FLIGHT for this
// booking, then insert a fresh one. This effectively extends the lock.
if _, err := db.DB.Exec(r.Context(), `
if _, err := db.Conn.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = 'PAYMENT_IN_FLIGHT:' || $1
`, bookingID); err != nil {
log.Printf("Failed to clear previous payment lock for booking %s: %v", bookingID, err)
}
if _, err := db.DB.Exec(r.Context(), `
if _, err := db.Conn.Exec(r.Context(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES (NOW(), $1, $2, $3)
`, PaymentLockDuration, "PAYMENT_IN_FLIGHT:"+bookingID, userID); err != nil {
@@ -1835,7 +1835,7 @@ func ReleasePaymentLock(w http.ResponseWriter, r *http.Request) {
return
}
if _, err := db.DB.Exec(r.Context(), `
if _, err := db.Conn.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = 'PAYMENT_IN_FLIGHT:' || $1
`, bookingID); err != nil {
+7 -7
View File
@@ -34,7 +34,7 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
}
var bookingUserID string
if err := db.DB.QueryRow(r.Context(), `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&bookingUserID); err != nil {
if err := db.Conn.QueryRow(r.Context(), `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&bookingUserID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "Booking not found", http.StatusNotFound)
return
@@ -49,7 +49,7 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
}
var bookingStatus string
if err := db.DB.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus); err != nil {
if err := db.Conn.QueryRow(r.Context(), `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&bookingStatus); err != nil {
log.Printf("Failed to get booking status: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
@@ -63,14 +63,14 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
// Loyalty redemption must be on the first payment — reject if a real payment
// (non-discount) already exists on this booking.
var realPaymentExists bool
db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method != 'discount')`, bookingID).Scan(&realPaymentExists)
db.Conn.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM payments WHERE booking_id = $1 AND payment_method != 'discount')`, bookingID).Scan(&realPaymentExists)
if realPaymentExists {
http.Error(w, "Loyalty must be redeemed on the first payment for this booking", http.StatusBadRequest)
return
}
var loyaltyStamps int
if err := db.DB.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&loyaltyStamps); err != nil {
if err := db.Conn.QueryRow(r.Context(), `SELECT loyalty_stamps FROM users WHERE id = $1`, userID).Scan(&loyaltyStamps); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "User not found", http.StatusNotFound)
return
@@ -85,7 +85,7 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
}
var redemptionID string
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT id FROM loyalty_redemptions
WHERE user_id = $1 AND status = 'pending' AND expires_at > NOW()
ORDER BY redeemed_at ASC LIMIT 1
@@ -100,14 +100,14 @@ func ApplyLoyaltyRedemption(w http.ResponseWriter, r *http.Request) {
}
var existingDiscount int
if err := db.DB.QueryRow(r.Context(), `
if err := db.Conn.QueryRow(r.Context(), `
SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty'
`, bookingID).Scan(&existingDiscount); err == nil {
http.Error(w, "A loyalty discount has already been applied to this booking", http.StatusBadRequest)
return
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
+37 -19
View File
@@ -102,7 +102,7 @@ func ProcessCancellationRefund(
// Get the booking's user info for refund routing.
var bookingUserID string
var isGuest bool
if err := db.DB.QueryRow(ctx, `
if err := db.Conn.QueryRow(ctx, `
SELECT b.user_id, COALESCE(u.account_role = 'guest', false)
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
@@ -112,7 +112,7 @@ func ProcessCancellationRefund(
// Non-fatal — we'll still process Square refunds but skip balance credits.
}
rows, err := db.DB.Query(ctx, `
rows, err := db.Conn.Query(ctx, `
SELECT id, amount, payment_method, square_payment_id, gift_card_id
FROM payments
WHERE booking_id = $1 AND status = 'completed' AND payment_method NOT IN ('discount', 'on_the_house')
@@ -122,7 +122,28 @@ func ProcessCancellationRefund(
log.Printf("Failed to fetch payments for refund: %v", err)
return &calc, nil
}
defer rows.Close()
// Read all payments into a slice, then close rows immediately.
// This avoids "conn busy" errors when db.Conn.QueryRow/Exec are called
// inside the processing loop with a per-test transaction (pgx.Tx does not
// support concurrent queries on the same connection).
type paymentRow struct {
ID string
Amount float64
PaymentMethod string
SquarePaymentID *string
GiftCardID *string
}
var payments []paymentRow
for rows.Next() {
var p paymentRow
if err := rows.Scan(&p.ID, &p.Amount, &p.PaymentMethod, &p.SquarePaymentID, &p.GiftCardID); err != nil {
log.Printf("Failed to scan payment row: %v", err)
continue
}
payments = append(payments, p)
}
rows.Close()
refundRemaining := calc.RefundableAmount
// Track which Square payment IDs have already been refunded through Square.
@@ -130,19 +151,16 @@ func ProcessCancellationRefund(
// square_payment_id — we must only refund each Square payment once.
refundedSquareIDs := make(map[string]bool)
for rows.Next() {
for _, p := range payments {
if refundRemaining <= 0 {
break
}
var paymentID, paymentMethod string
var amount float64
var squarePaymentID *string
var giftCardID *string
if err := rows.Scan(&paymentID, &amount, &paymentMethod, &squarePaymentID, &giftCardID); err != nil {
log.Printf("Failed to scan payment row: %v", err)
continue
}
paymentID := p.ID
paymentMethod := p.PaymentMethod
amount := p.Amount
squarePaymentID := p.SquarePaymentID
giftCardID := p.GiftCardID
refundThisPayment := math.Min(amount, refundRemaining)
refundCents := int64(math.Round(refundThisPayment * 100))
@@ -188,7 +206,7 @@ func ProcessCancellationRefund(
break
}
var expired bool
if err := db.DB.QueryRow(ctx, `
if err := db.Conn.QueryRow(ctx, `
SELECT expiry_date IS NOT NULL AND expiry_date < NOW()
FROM gift_cards WHERE id = $1
`, *giftCardID).Scan(&expired); err != nil {
@@ -197,14 +215,14 @@ func ProcessCancellationRefund(
log.Printf("Gift card %s has expired — money retained by salon, no refund due for booking %s", *giftCardID, bookingID)
break
}
if _, err := db.DB.Exec(ctx, `
if _, err := db.Conn.Exec(ctx, `
UPDATE gift_cards SET amount_remaining = amount_remaining + $1, last_used_at = NOW()
WHERE id = $2
`, refundThisPayment, *giftCardID); err != nil {
log.Printf("Failed to refund £%.2f to gift card %s: %v", refundThisPayment, *giftCardID, err)
break
}
if _, err := db.DB.Exec(ctx, `
if _, err := db.Conn.Exec(ctx, `
INSERT INTO gift_card_transactions (gift_card_id, transaction_type, amount, reference_type, reference_id, user_id, notes)
VALUES ($1, 'refund', $2, 'booking', $3, $4, $5)
`, *giftCardID, refundThisPayment, bookingID, bookingUserID, "Refund from cancelled booking"); err != nil {
@@ -235,7 +253,7 @@ func ProcessCancellationRefund(
CreatedAt: time.Now(),
}
_, dbErr := db.DB.Exec(ctx, `
_, dbErr := db.Conn.Exec(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at)
VALUES ($1, $2, $3, $4, 'completed', $5, $6, $7)
`, record.PaymentID, record.BookingID, record.Amount, record.SquareRefundID, record.Reason, record.CreatedBy, record.CreatedAt)
@@ -249,9 +267,9 @@ func ProcessCancellationRefund(
if bookingUserID != "" {
var loyaltyUsed bool
db.DB.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')", bookingID).Scan(&loyaltyUsed)
db.Conn.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM booking_discounts WHERE booking_id = $1 AND discount_source = 'loyalty')", bookingID).Scan(&loyaltyUsed)
if loyaltyUsed {
_, err := db.DB.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID)
_, err := db.Conn.Exec(ctx, "UPDATE users SET loyalty_stamps = loyalty_stamps + $1 WHERE id = $2", LoyaltyStampCost, bookingUserID)
if err != nil {
log.Printf("Failed to refund loyalty stamps for booking %s: %v", bookingID, err)
} else {
@@ -267,7 +285,7 @@ func ProcessCancellationRefund(
// The refunds table (payment_id, booking_id, amount, reason, created_by, created_at)
// provides the primary audit trail for FreeAgent reconciliation.
func creditUserBalance(ctx context.Context, userID, bookingID, paymentID string, amount float64, reason string) {
_, err := db.DB.Exec(ctx, `
_, err := db.Conn.Exec(ctx, `
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id) DO UPDATE SET
+19 -19
View File
@@ -83,7 +83,7 @@ func (s *PaymentService) CalculateFees(amount int64, method string) float64 {
}
func (s *PaymentService) CreatePaymentRecord(ctx context.Context, record PaymentRecord, giftCardID *string) (string, error) {
return s.insertPaymentRecord(ctx, record, giftCardID, db.DB)
return s.insertPaymentRecord(ctx, record, giftCardID, db.Conn)
}
// CreatePaymentRecordTx is identical to CreatePaymentRecord but accepts a
@@ -147,7 +147,7 @@ func (s *PaymentService) insertPaymentRecord(ctx context.Context, record Payment
func (s *PaymentService) CreateRefundRecord(ctx context.Context, record RefundRecord) (string, error) {
var id string
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
INSERT INTO refunds (
payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
@@ -176,7 +176,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
}
var totalAmount float64
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
SELECT COALESCE(SUM(price_val), 0) FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
FROM booking_services bs
@@ -195,7 +195,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
}
summary.TotalAmount = totalAmount
rows, err := db.DB.Query(ctx, `
rows, err := db.Conn.Query(ctx, `
SELECT p.id, p.booking_id, p.payment_type, p.payment_method, p.vendor_code, p.invoice_number,
p.status, p.amount, COALESCE(usc.last_4, ''), p.is_vat_applicable, p.vat_rate, p.vat_amount, p.net_amount,
p.user_saved_card_id, p.square_payment_id, p.idempotency_key, p.fees, p.created_at, p.updated_at, p.created_by,
@@ -230,7 +230,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
}
summary.PaidAmount = paidAmount
refundRows, err := db.DB.Query(ctx, `
refundRows, err := db.Conn.Query(ctx, `
SELECT id, payment_id, booking_id, amount, square_refund_id, status, reason, created_by, created_at
FROM refunds
WHERE booking_id = $1 AND status = 'completed'
@@ -263,7 +263,7 @@ func (s *PaymentService) GetBookingPaymentSummary(ctx context.Context, bookingID
func (s *PaymentService) CheckIdempotency(ctx context.Context, bookingID, idempotencyKey string) (*PaymentRecord, error) {
var p PaymentRecord
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by,
@@ -288,7 +288,7 @@ func (s *PaymentService) CheckIdempotency(ctx context.Context, bookingID, idempo
func (s *PaymentService) CheckIdempotencyByKey(ctx context.Context, idempotencyKey string) (*PaymentRecord, error) {
var p PaymentRecord
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by,
@@ -313,7 +313,7 @@ func (s *PaymentService) CheckIdempotencyByKey(ctx context.Context, idempotencyK
func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) (*PaymentRecord, error) {
var p PaymentRecord
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
SELECT id, booking_id, payment_type, payment_method, vendor_code, invoice_number,
status, amount, is_vat_applicable, vat_rate, vat_amount, net_amount,
user_saved_card_id, square_payment_id, idempotency_key, fees, created_at, updated_at, created_by,
@@ -335,7 +335,7 @@ func (s *PaymentService) GetPaymentByID(ctx context.Context, paymentID string) (
func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID string) (int64, error) {
var amount float64
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
SELECT COALESCE(SUM(amount), 0) FROM refunds
WHERE payment_id = $1 AND status = 'completed'
`, paymentID).Scan(&amount)
@@ -348,7 +348,7 @@ func (s *PaymentService) GetAlreadyRefundedAmount(ctx context.Context, paymentID
func (s *PaymentService) HasCompletedPayment(ctx context.Context, bookingID string) (bool, error) {
var count int
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
SELECT COUNT(*) FROM payments
WHERE booking_id = $1 AND status = 'completed' AND payment_type IN ('full', 'deposit', 'balance', 'partial')
`, bookingID).Scan(&count)
@@ -360,7 +360,7 @@ func (s *PaymentService) HasCompletedPayment(ctx context.Context, bookingID stri
func (s *PaymentService) GetBookingStatus(ctx context.Context, bookingID string) (string, error) {
var status string
err := db.DB.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)
err := db.Conn.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)
if err != nil {
return "", err
}
@@ -379,7 +379,7 @@ type BookingPaymentInfo struct {
// total completed payments for a booking.
func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID string) (*BookingPaymentInfo, error) {
var info BookingPaymentInfo
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
SELECT b.start_time, b.status,
COALESCE(bt.total_amount, 0),
COALESCE(pt.total_paid, 0)
@@ -407,7 +407,7 @@ func (s *PaymentService) GetBookingPaymentInfo(ctx context.Context, bookingID st
func (s *PaymentService) GetBookingUserID(ctx context.Context, bookingID string) (string, error) {
var userID string
err := db.DB.QueryRow(ctx, `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&userID)
err := db.Conn.QueryRow(ctx, `SELECT user_id FROM bookings WHERE id = $1`, bookingID).Scan(&userID)
if err != nil {
return "", err
}
@@ -416,7 +416,7 @@ func (s *PaymentService) GetBookingUserID(ctx context.Context, bookingID string)
func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bookingID string) (int64, error) {
var remainingCents int64
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
WITH booking_total AS (
SELECT COALESCE(SUM(price_val), 0) AS total_pounds FROM (
SELECT COALESCE(bs.override_price, s.price) AS price_val
@@ -445,7 +445,7 @@ func (s *PaymentService) GetBookingRemainingBalanceCents(ctx context.Context, bo
}
func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID string) ([]SavedCard, error) {
rows, err := db.DB.Query(ctx, `
rows, err := db.Conn.Query(ctx, `
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default
FROM user_saved_cards
WHERE user_id = $1 AND deleted_at IS NULL
@@ -476,7 +476,7 @@ func (s *PaymentService) GetUserPaymentMethods(ctx context.Context, userID strin
func (s *PaymentService) DeletePaymentMethod(ctx context.Context, cardID, userID string) error {
retainedUntil := time.Now().Add(7 * 365 * 24 * time.Hour)
_, err := db.DB.Exec(ctx, `
_, err := db.Conn.Exec(ctx, `
UPDATE user_saved_cards
SET deleted_at = NOW(), deleted_by = $1, retained_until = $2
WHERE id = $3 AND user_id = $1
@@ -514,7 +514,7 @@ func (s *PaymentService) CreatePaymentMethodFromDetails(ctx context.Context, use
var savedCardID string
var isDefault bool
err = db.DB.QueryRow(ctx, `
err = db.Conn.QueryRow(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default)
SELECT $1, $2, $3, $4, $5, $6, $7,
NOT EXISTS(SELECT 1 FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL)
@@ -538,7 +538,7 @@ func (s *PaymentService) CreatePaymentMethodFromDetails(ctx context.Context, use
func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCardID, brand, last4 string, expMonth, expYear int, fingerprint string) (string, error) {
var id string
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
INSERT INTO user_saved_cards (
user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, false, NOW())
@@ -553,7 +553,7 @@ func (s *PaymentService) SaveCardForUser(ctx context.Context, userID, squareCard
func (s *PaymentService) GetCardByID(ctx context.Context, cardID, userID string) (*SavedCard, error) {
var c SavedCard
err := db.DB.QueryRow(ctx, `
err := db.Conn.QueryRow(ctx, `
SELECT id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default
FROM user_saved_cards
WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL
+5 -5
View File
@@ -93,7 +93,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
// Idempotency check: if key provided, return existing sale if found
if req.IdempotencyKey != "" {
var existingID string
err := db.DB.QueryRow(ctx, `SELECT id FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID)
err := db.Conn.QueryRow(ctx, `SELECT id FROM till_sales WHERE idempotency_key = $1`, req.IdempotencyKey).Scan(&existingID)
if err == nil {
// Existing sale found — return it (idempotent)
w.Header().Set("Content-Type", "application/json")
@@ -110,7 +110,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
service := NewPaymentService()
tx, err := db.DB.Begin(ctx)
tx, err := db.Conn.Begin(ctx)
if err != nil {
log.Printf("Failed to begin transaction: %v", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
@@ -261,7 +261,7 @@ func CreateTillSale(w http.ResponseWriter, r *http.Request) {
}
var sqCardID string
err = db.DB.QueryRow(ctx, `
err = db.Conn.QueryRow(ctx, `
SELECT square_card_id
FROM user_saved_cards
WHERE id = $1 AND deleted_at IS NULL
@@ -423,7 +423,7 @@ func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) {
var tillSaleID string
var currentStatus string
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT id, status FROM till_sales
WHERE square_checkout_id = $1
`, checkoutID).Scan(&tillSaleID, &currentStatus)
@@ -458,7 +458,7 @@ func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) {
}
if paymentResult.Status == "COMPLETED" {
_, err = db.DB.Exec(r.Context(), `
_, err = db.Conn.Exec(r.Context(), `
UPDATE till_sales
SET status = 'completed',
square_payment_id = $1,
+10 -10
View File
@@ -71,7 +71,7 @@ func validateInputLength(input string) error {
// getAllowedCategories fetches all unique category prefixes from existing tags
func getAllowedCategories(ctx context.Context) (map[string]bool, error) {
rows, err := db.DB.Query(ctx, `
rows, err := db.Conn.Query(ctx, `
SELECT DISTINCT SPLIT_PART(t, ':', 1) as category
FROM images, unnest(tag_names) as t
WHERE t LIKE '%:%'
@@ -312,7 +312,7 @@ func ListImages(w http.ResponseWriter, r *http.Request) {
args = queryArgs
}
rows, err := db.DB.Query(r.Context(), query, args...)
rows, err := db.Conn.Query(r.Context(), query, args...)
if err != nil {
log.Printf("Failed to list images: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -415,7 +415,7 @@ func ListTags(w http.ResponseWriter, r *http.Request) {
`
}
rows, err := db.DB.Query(r.Context(), query, args...)
rows, err := db.Conn.Query(r.Context(), query, args...)
if err != nil {
log.Printf("Failed to list tags: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -527,7 +527,7 @@ func ListFilters(w http.ResponseWriter, r *http.Request) {
filteredQuery := filterQuery + filterClause + " GROUP BY category, value ORDER BY category, count DESC"
rows, err := db.DB.Query(r.Context(), filteredQuery, otherArgs...)
rows, err := db.Conn.Query(r.Context(), filteredQuery, otherArgs...)
if err != nil {
log.Printf("Failed to list filters: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -554,7 +554,7 @@ func ListFilters(w http.ResponseWriter, r *http.Request) {
// Query 2: Get selected categories WITHOUT filters (show all options)
unfilteredQuery := filterQuery + " GROUP BY category, value ORDER BY category, count DESC"
rows, err = db.DB.Query(r.Context(), unfilteredQuery, args...)
rows, err = db.Conn.Query(r.Context(), unfilteredQuery, args...)
if err != nil {
log.Printf("Failed to list unfiltered filters: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -608,7 +608,7 @@ func ListFilters(w http.ResponseWriter, r *http.Request) {
// No selected categories - simple query
finalQuery := filterQuery + " GROUP BY category, value ORDER BY category, count DESC"
rows, err := db.DB.Query(r.Context(), finalQuery, args...)
rows, err := db.Conn.Query(r.Context(), finalQuery, args...)
if err != nil {
log.Printf("Failed to list filters: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -848,7 +848,7 @@ func UploadImage(w http.ResponseWriter, r *http.Request) {
}
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -922,7 +922,7 @@ func DeleteImage(w http.ResponseWriter, r *http.Request) {
var img Image
var fullAvif, fullWebp, fullJpg, fullJxl sql.NullString
var thumbAvif, thumbWebp, thumbJpg sql.NullString
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT url, thumbnail_url,
full_avif_url, full_webp_url, full_jpg_url, full_jxl_url,
thumb_avif_url, thumb_webp_url, thumb_jpg_url
@@ -976,7 +976,7 @@ func DeleteImage(w http.ResponseWriter, r *http.Request) {
}
}
_, err = db.DB.Exec(r.Context(), `DELETE FROM images WHERE id = $1`, imageID)
_, err = db.Conn.Exec(r.Context(), `DELETE FROM images WHERE id = $1`, imageID)
if err != nil {
log.Printf("Failed to delete image: %v", err)
http.Error(w, "Failed to delete image", http.StatusInternalServerError)
@@ -1033,7 +1033,7 @@ func GetImage(w http.ResponseWriter, r *http.Request) {
searchPattern := "%" + imageID + ".%"
var fullAvif, fullWebp, fullJpg, fullJxl sql.NullString
var thumbAvif, thumbWebp, thumbJpg sql.NullString
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT id, url, thumbnail_url, tag_names, created_at,
full_avif_url, full_webp_url, full_jpg_url, full_jxl_url,
thumb_avif_url, thumb_webp_url, thumb_jpg_url
+9 -9
View File
@@ -33,7 +33,7 @@ type DayWorkingHours struct {
// --- Default Hours Handlers ---
func GetDefaultHours(w http.ResponseWriter, r *http.Request) {
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT weekday, start_time::text, end_time::text, is_open
FROM working_hours ORDER BY weekday
`)
@@ -85,7 +85,7 @@ func UpdateDefaultHours(w http.ResponseWriter, r *http.Request) {
}
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "failed to start tx", http.StatusInternalServerError)
return
@@ -139,7 +139,7 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) {
// Load default hours
defaultMap := map[int]DefaultHours{}
defRows, _ := db.DB.Query(r.Context(), `
defRows, _ := db.Conn.Query(r.Context(), `
SELECT weekday, start_time::text, end_time::text, is_open
FROM working_hours
`)
@@ -160,7 +160,7 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) {
}
queryStart := start.AddDate(0, 0, -daysSinceMonday)
appRows, _ := db.DB.Query(r.Context(), `
appRows, _ := db.Conn.Query(r.Context(), `
SELECT group_id, week_start
FROM exceptional_group_applications
WHERE week_start BETWEEN $1 AND $2
@@ -184,7 +184,7 @@ func GetWorkingHours(w http.ResponseWriter, r *http.Request) {
exHoursMap := map[int]map[int]ExceptionalHours{}
if len(groupIDs) > 0 {
query, args, _ := sqlIn("SELECT group_id, weekday, start_time::text, end_time::text, is_open FROM exceptional_working_hours WHERE group_id IN (%s)", groupIDs)
rows, _ := db.DB.Query(r.Context(), query, args...)
rows, _ := db.Conn.Query(r.Context(), query, args...)
for rows.Next() {
var h ExceptionalHours
if err := rows.Scan(&h.GroupID, &h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err == nil {
@@ -365,7 +365,7 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
// Load default hours
defaultMap := map[int]DefaultHours{}
defRows, _ := db.DB.Query(r.Context(), `SELECT weekday, start_time::text, end_time::text, is_open FROM working_hours`)
defRows, _ := db.Conn.Query(r.Context(), `SELECT weekday, start_time::text, end_time::text, is_open FROM working_hours`)
for defRows.Next() {
var d DefaultHours
if err := defRows.Scan(&d.Weekday, &d.StartTime, &d.EndTime, &d.IsOpen); err == nil {
@@ -383,7 +383,7 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
}
queryStart := start.AddDate(0, 0, -daysSinceMonday)
appRows, _ := db.DB.Query(r.Context(), `
appRows, _ := db.Conn.Query(r.Context(), `
SELECT group_id, week_start
FROM exceptional_group_applications
WHERE week_start BETWEEN $1 AND $2
@@ -407,7 +407,7 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
exHoursMap := map[int]map[int]ExceptionalHours{}
if len(groupIDs) > 0 {
query, args, _ := sqlIn("SELECT group_id, weekday, start_time::text, end_time::text, is_open FROM exceptional_working_hours WHERE group_id IN (%s)", groupIDs)
rows, _ := db.DB.Query(r.Context(), query, args...)
rows, _ := db.Conn.Query(r.Context(), query, args...)
for rows.Next() {
var h ExceptionalHours
if err := rows.Scan(&h.GroupID, &h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err == nil {
@@ -421,7 +421,7 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
}
// Load bookings
bookingRows, _ := db.DB.Query(r.Context(), `
bookingRows, _ := db.Conn.Query(r.Context(), `
SELECT
b.start_time,
COALESCE((SELECT SUM(dur) FROM (
@@ -29,7 +29,7 @@ type ExceptionalGroup struct {
// --- List Groups with Hours and Applications ---
func ListExceptionalGroups(w http.ResponseWriter, r *http.Request) {
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT id, name, description
FROM exceptional_working_hours_groups
ORDER BY id DESC
@@ -38,22 +38,34 @@ func ListExceptionalGroups(w http.ResponseWriter, r *http.Request) {
http.Error(w, "failed to fetch groups", http.StatusInternalServerError)
return
}
defer rows.Close()
// Collect all groups first, then close rows to avoid "conn busy" when
// the context carries a test transaction (single connection).
var groups []ExceptionalGroup
for rows.Next() {
var g ExceptionalGroup
if err := rows.Scan(&g.ID, &g.Name, &g.Description); err != nil {
rows.Close()
http.Error(w, "failed to scan group", http.StatusInternalServerError)
return
}
groups = append(groups, g)
}
rows.Close()
if err := rows.Err(); err != nil {
http.Error(w, "error iterating groups", http.StatusInternalServerError)
return
}
// Load hours and applications for each group (separate queries after rows are closed)
for i := range groups {
// Load 7-day hours
hoursRows, err := db.DB.Query(r.Context(), `
hoursRows, err := db.Conn.Query(r.Context(), `
SELECT id, weekday, start_time::text, end_time::text, is_open
FROM exceptional_working_hours
WHERE group_id=$1 ORDER BY weekday
`, g.ID)
`, groups[i].ID)
if err != nil {
http.Error(w, "failed to fetch group hours", http.StatusInternalServerError)
return
@@ -66,8 +78,8 @@ func ListExceptionalGroups(w http.ResponseWriter, r *http.Request) {
http.Error(w, "failed to scan hours", http.StatusInternalServerError)
return
}
h.GroupID = g.ID
g.Hours = append(g.Hours, h)
h.GroupID = groups[i].ID
groups[i].Hours = append(groups[i].Hours, h)
}
hoursRows.Close()
@@ -77,11 +89,11 @@ func ListExceptionalGroups(w http.ResponseWriter, r *http.Request) {
}
// Load applied week starts
weekRows, err := db.DB.Query(r.Context(), `
weekRows, err := db.Conn.Query(r.Context(), `
SELECT week_start
FROM exceptional_group_applications
WHERE group_id=$1 ORDER BY week_start
`, g.ID)
`, groups[i].ID)
if err != nil {
http.Error(w, "failed to fetch applications", http.StatusInternalServerError)
return
@@ -94,7 +106,7 @@ func ListExceptionalGroups(w http.ResponseWriter, r *http.Request) {
http.Error(w, "failed to scan week_start", http.StatusInternalServerError)
return
}
g.WeekStarts = append(g.WeekStarts, weekStart.Format("2006-01-02"))
groups[i].WeekStarts = append(groups[i].WeekStarts, weekStart.Format("2006-01-02"))
}
weekRows.Close()
@@ -102,13 +114,6 @@ func ListExceptionalGroups(w http.ResponseWriter, r *http.Request) {
http.Error(w, "error iterating applications", http.StatusInternalServerError)
return
}
groups = append(groups, g)
}
if err := rows.Err(); err != nil {
http.Error(w, "error iterating groups", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
@@ -175,7 +180,7 @@ func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) {
parsedWeeks = append(parsedWeeks, weekStart)
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "failed to start transaction", http.StatusInternalServerError)
return
@@ -251,7 +256,7 @@ func DeleteExceptionalGroup(w http.ResponseWriter, r *http.Request) {
return
}
result, err := db.DB.Exec(r.Context(), `
result, err := db.Conn.Exec(r.Context(), `
DELETE FROM exceptional_working_hours_groups WHERE id=$1
`, id)
if err != nil {
@@ -306,7 +311,7 @@ func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) {
parsedWeeks = append(parsedWeeks, weekStart)
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "failed to start transaction", http.StatusInternalServerError)
return
+21 -21
View File
@@ -62,7 +62,7 @@ func ListTimeBlockers(w http.ResponseWriter, r *http.Request) {
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 999999999, ukLocation)
// Get one-off blockers in range + ALL recurring blockers
rows, err = db.DB.Query(r.Context(), `
rows, err = db.Conn.Query(r.Context(), `
SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
FROM time_blockers
WHERE (cron_expression IS NULL AND start_time >= $1 AND start_time <= $2)
@@ -74,7 +74,7 @@ func ListTimeBlockers(w http.ResponseWriter, r *http.Request) {
} else {
// Get future one-off blockers + ALL recurring blockers
now := time.Now()
rows, err = db.DB.Query(r.Context(), `
rows, err = db.Conn.Query(r.Context(), `
SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
FROM time_blockers
WHERE (cron_expression IS NULL AND start_time >= $1)
@@ -147,7 +147,7 @@ func CreateTimeBlocker(w http.ResponseWriter, r *http.Request) {
// Insert the time blocker
var blocker TimeBlocker
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, cron_expression, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, start_time, duration_minutes, description, cron_expression, created_at, created_by
@@ -174,7 +174,7 @@ func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) {
return
}
result, err := db.DB.Exec(r.Context(), `
result, err := db.Conn.Exec(r.Context(), `
DELETE FROM time_blockers WHERE id = $1
`, id)
if err != nil {
@@ -198,7 +198,7 @@ func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) {
// Used by GetAvailableHours to subtract blocked time from available slots
func GetTimeBlockersInRange(ctx context.Context, start, end time.Time) ([]TimeBlocker, error) {
// Get one-off blockers in range
rows, err := db.DB.Query(ctx, `
rows, err := db.Conn.Query(ctx, `
SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
FROM time_blockers
WHERE cron_expression IS NULL
@@ -222,7 +222,7 @@ func GetTimeBlockersInRange(ctx context.Context, start, end time.Time) ([]TimeBl
rows.Close()
// Get ALL recurring blockers and expand them
recurringRows, err := db.DB.Query(ctx, `
recurringRows, err := db.Conn.Query(ctx, `
SELECT id, start_time, duration_minutes, description, cron_expression, created_at, created_by
FROM time_blockers
WHERE cron_expression IS NOT NULL
@@ -351,7 +351,7 @@ func CleanupOldReservations(ctx context.Context) error {
fifteenMinutesAgo := time.Now().Add(-15 * time.Minute)
twentyFourHoursAgo := time.Now().Add(-24 * time.Hour)
_, err := db.DB.Exec(ctx, `
_, err := db.Conn.Exec(ctx, `
DELETE FROM time_blockers
WHERE (description LIKE 'RESERVATION:user:%' AND created_at < $1)
OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2)
@@ -368,7 +368,7 @@ func CleanupOldReservations(ctx context.Context) error {
// Financial records (bookings, payments) remain intact — only PII is scrubbed.
// Active/pending bookings are excluded so the salon can still contact the guest.
func AnonymizeStaleGuestAccounts(ctx context.Context) error {
_, err := db.DB.Exec(ctx, `
_, err := db.Conn.Exec(ctx, `
UPDATE users SET
n_first_name = 'Guest',
n_last_name = 'Anonymized',
@@ -397,7 +397,7 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error {
}
// Anonymize patch test records for stale guests (medical-adjacent PII)
_, err = db.DB.Exec(ctx, `
_, err = db.Conn.Exec(ctx, `
UPDATE user_patch_tests SET user_id = NULL
WHERE user_id IN (
SELECT id FROM users
@@ -411,7 +411,7 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error {
}
// Anonymize referral relationships for stale guests
_, err = db.DB.Exec(ctx, `
_, err = db.Conn.Exec(ctx, `
UPDATE user_referrals SET referrer_id = NULL
WHERE referrer_id IN (
SELECT id FROM users
@@ -424,7 +424,7 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error {
return err
}
_, err = db.DB.Exec(ctx, `
_, err = db.Conn.Exec(ctx, `
UPDATE user_referrals SET referred_id = NULL
WHERE referred_id IN (
SELECT id FROM users
@@ -438,7 +438,7 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error {
}
// Anonymize admin notification references for stale guests
_, err = db.DB.Exec(ctx, `
_, err = db.Conn.Exec(ctx, `
UPDATE admin_notifications SET user_id = NULL
WHERE user_id IN (
SELECT id FROM users
@@ -451,7 +451,7 @@ func AnonymizeStaleGuestAccounts(ctx context.Context) error {
}
func CleanupExpiredLoyaltyRedemptions(ctx context.Context) error {
_, err := db.DB.Exec(ctx, `
_, err := db.Conn.Exec(ctx, `
DELETE FROM loyalty_redemptions
WHERE status = 'pending'
AND expires_at < NOW()
@@ -469,7 +469,7 @@ func CleanupExpiredLoyaltyRedemptions(ctx context.Context) error {
//
// The function is idempotent — running it twice produces the same result.
func CleanupExpiredFinancialRecords(ctx context.Context) error {
tx, err := db.DB.Begin(ctx)
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
@@ -591,7 +591,7 @@ func CleanupExpiredFinancialRecords(ctx context.Context) error {
// If the deposit IS paid after the deadline but before the appointment, the
// booking flips back to confirmed.
func CleanupExpiredDeposits(ctx context.Context) error {
tx, err := db.DB.Begin(ctx)
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
@@ -718,7 +718,7 @@ func CleanupExpiredDeposits(ctx context.Context) error {
// (bought as gifts, change hands) until redeemed to an account. After redemption,
// the balance is covered by CleanupIdleAccounts warnings.
func CleanupExpiredGiftCards(ctx context.Context) error {
tx, err := db.DB.Begin(ctx)
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
@@ -816,7 +816,7 @@ func CleanupExpiredGiftCards(ctx context.Context) error {
//
// Store sent warnings in account_email_warnings table to avoid duplicates.
func CleanupIdleAccounts(ctx context.Context) error {
tx, err := db.DB.Begin(ctx)
tx, err := db.Conn.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
@@ -921,7 +921,7 @@ func CleanupIdleAccounts(ctx context.Context) error {
// till_sales that are older than 24 hours and no longer pending. This prevents
// unbounded table growth while preserving keys for recent in-flight requests.
func CleanupOldIdempotencyKeys(ctx context.Context) error {
_, err := db.DB.Exec(ctx, `
_, err := db.Conn.Exec(ctx, `
UPDATE bookings
SET idempotency_key = NULL
WHERE idempotency_key IS NOT NULL
@@ -932,7 +932,7 @@ func CleanupOldIdempotencyKeys(ctx context.Context) error {
return fmt.Errorf("failed to cleanup booking idempotency keys: %w", err)
}
_, err = db.DB.Exec(ctx, `
_, err = db.Conn.Exec(ctx, `
UPDATE payments
SET idempotency_key = NULL
WHERE idempotency_key IS NOT NULL
@@ -943,7 +943,7 @@ func CleanupOldIdempotencyKeys(ctx context.Context) error {
return fmt.Errorf("failed to cleanup payment idempotency keys: %w", err)
}
_, err = db.DB.Exec(ctx, `
_, err = db.Conn.Exec(ctx, `
UPDATE till_sales
SET idempotency_key = NULL
WHERE idempotency_key IS NOT NULL
@@ -961,7 +961,7 @@ func CleanupOldIdempotencyKeys(ctx context.Context) error {
// to be retained indefinitely. 6 months provides a reasonable window for
// displaying former names on booking receipts and admin views.
func CleanupOldNameHistory(ctx context.Context) error {
_, err := db.DB.Exec(ctx, `
_, err := db.Conn.Exec(ctx, `
DELETE FROM name_history
WHERE changed_at < NOW() - INTERVAL '6 months'
`)
+19 -17
View File
@@ -61,7 +61,7 @@ func ToggleService(w http.ResponseWriter, r *http.Request) {
}
query := "UPDATE services SET is_active = NOT is_active WHERE id = $1"
result, err := db.DB.Exec(r.Context(), query, serviceID)
result, err := db.Conn.Exec(r.Context(), query, serviceID)
if err != nil {
http.Error(w, "Failed to toggle service: "+err.Error(), http.StatusInternalServerError)
return
@@ -136,7 +136,7 @@ func CreateServiceHandler(w http.ResponseWriter, r *http.Request) {
var service Service
var createdByDB sql.NullString
err := db.DB.QueryRow(r.Context(),
err := db.Conn.QueryRow(r.Context(),
query,
req.Name,
req.Description,
@@ -201,7 +201,7 @@ func DeleteServiceHandler(w http.ResponseWriter, r *http.Request) {
// Use soft delete - set is_active to FALSE instead of hard delete
// This preserves referential integrity with booking_services
query := "UPDATE services SET is_active = FALSE WHERE id = $1"
result, err := db.DB.Exec(r.Context(), query, serviceID)
result, err := db.Conn.Exec(r.Context(), query, serviceID)
if err != nil {
http.Error(w, "Failed to delete service: "+err.Error(), http.StatusInternalServerError)
return
@@ -254,7 +254,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
ORDER BY s.name
`
rows, err := db.DB.Query(r.Context(), query)
rows, err := db.Conn.Query(r.Context(), query)
if err != nil {
http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError)
return
@@ -304,7 +304,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
// User is logged in and not admin - check eligibility
// Get user's date of birth
var dob time.Time
err := db.DB.QueryRow(r.Context(), `SELECT date_of_birth FROM users WHERE id = $1`, userID).Scan(&dob)
err := db.Conn.QueryRow(r.Context(), `SELECT date_of_birth FROM users WHERE id = $1`, userID).Scan(&dob)
if err != nil {
http.Error(w, "Failed to get user data: "+err.Error(), http.StatusInternalServerError)
return
@@ -317,6 +317,10 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
age--
}
// Preload patch test data once to avoid N+1 queries.
// Must be done BEFORE querying services so the tx connection isn't busy.
patchTests := loadPatchTests(r.Context(), userID)
// Get all active services
query := `
SELECT s.id, s.name, s.description, s.price, s.duration_minutes,
@@ -327,7 +331,7 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
ORDER BY s.name
`
rows, err := db.DB.Query(r.Context(), query)
rows, err := db.Conn.Query(r.Context(), query)
if err != nil {
http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError)
return
@@ -337,9 +341,6 @@ func ServicesHandler(w http.ResponseWriter, r *http.Request) {
var services []ServiceResponse
var ineligibleServices []ServiceResponse
// Preload patch test data once to avoid N+1 queries
patchTests := loadPatchTests(r.Context(), userID)
for rows.Next() {
var service ServiceResponse
@@ -407,7 +408,7 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) {
// Get user's date of birth
var dob time.Time
err := db.DB.QueryRow(r.Context(), `SELECT date_of_birth FROM users WHERE id = $1`, userID).Scan(&dob)
err := db.Conn.QueryRow(r.Context(), `SELECT date_of_birth FROM users WHERE id = $1`, userID).Scan(&dob)
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "user not found", http.StatusNotFound)
return
@@ -424,6 +425,10 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) {
age--
}
// Preload patch test data once to avoid N+1 queries.
// Must be done BEFORE querying services so the tx connection isn't busy.
patchTests := loadPatchTests(r.Context(), userID)
// Get all active services
query := `
SELECT s.id, s.name, s.description, s.price, s.duration_minutes,
@@ -434,7 +439,7 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) {
ORDER BY s.name
`
rows, err := db.DB.Query(r.Context(), query)
rows, err := db.Conn.Query(r.Context(), query)
if err != nil {
http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError)
return
@@ -444,9 +449,6 @@ func ServicesEligibleForUserHandler(w http.ResponseWriter, r *http.Request) {
var services []ServiceResponse
var grayedOutServices []ServiceResponse
// Preload patch test data once to avoid N+1 queries
patchTests := loadPatchTests(r.Context(), userID)
for rows.Next() {
var service ServiceResponse
@@ -516,7 +518,7 @@ func loadPatchTests(ctx context.Context, userID string) map[string]*patchTestInf
result := make(map[string]*patchTestInfo)
// Query 1: load all patch_test records
rows, err := db.DB.Query(ctx, `
rows, err := db.Conn.Query(ctx, `
SELECT id, notice_duration_hours, expiry_months, service_ids
FROM patch_tests
`)
@@ -547,7 +549,7 @@ func loadPatchTests(ctx context.Context, userID string) map[string]*patchTestInf
// Query 2: load user_patch_test records for this user
testedAtMap := make(map[string]time.Time)
if len(patchTests) > 0 {
uRows, err := db.DB.Query(ctx, `
uRows, err := db.Conn.Query(ctx, `
SELECT patch_test_id, tested_at
FROM user_patch_tests
WHERE user_id = $1
@@ -632,7 +634,7 @@ func AllServicesHandler(w http.ResponseWriter, r *http.Request) {
ORDER BY s.is_active DESC, s.name
`
rows, err := db.DB.Query(r.Context(), query)
rows, err := db.Conn.Query(r.Context(), query)
if err != nil {
http.Error(w, "Failed to fetch services: "+err.Error(), http.StatusInternalServerError)
return
+21 -21
View File
@@ -82,7 +82,7 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
todayEnd := todayStart.Add(24 * time.Hour)
// Auto-transition confirmed bookings that have started but not ended to in_progress
_, err := db.DB.Exec(r.Context(), `
_, err := db.Conn.Exec(r.Context(), `
UPDATE bookings
SET status = 'in_progress'
WHERE status = 'confirmed'
@@ -109,7 +109,7 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
}
// Auto-transition in_progress bookings that have ended to completed
_, err = db.DB.Exec(r.Context(), `
_, err = db.Conn.Exec(r.Context(), `
UPDATE bookings
SET status = 'completed'
WHERE status = 'in_progress'
@@ -266,7 +266,7 @@ func computeAggregateSummary(r *http.Request, rangeStart, rangeEnd time.Time) *D
summary := &DailySummary{}
// Single combined query replacing 9 separate round-trips
_ = db.DB.QueryRow(r.Context(), `
_ = db.Conn.QueryRow(r.Context(), `
SELECT
COALESCE((SELECT SUM(p.amount)
FROM bookings b
@@ -405,7 +405,7 @@ func findNewBookingServices(r *http.Request, rangeStart time.Time) []ServiceBook
var lastClose time.Time
var dayDate time.Time
var closeTimeStr string
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
WITH days AS (
SELECT
(NOW() - make_interval(days => i))::date AS day_date,
@@ -454,7 +454,7 @@ func findNewBookingServices(r *http.Request, rangeStart time.Time) []ServiceBook
}
// Query services across all new bookings, aggregated by service name
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT name, COUNT(*) as count
FROM (
SELECT s.name FROM booking_services bs2
@@ -502,7 +502,7 @@ func isDayOpen(r *http.Request, date time.Time) bool {
dateStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
var exceptionalOpen sql.NullBool
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT ewh.is_open
FROM exceptional_working_hours ewh
JOIN exceptional_working_hours_groups ewhg ON ewhg.id = ewh.group_id
@@ -518,7 +518,7 @@ func isDayOpen(r *http.Request, date time.Time) bool {
}
var isOpen bool
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT is_open FROM working_hours WHERE weekday = $1
`, weekday).Scan(&isOpen)
@@ -573,7 +573,7 @@ func getClosingTime(r *http.Request, date time.Time) string {
// Check exceptional hours first
var exceptionalClose sql.NullString
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT ewh.end_time::text
FROM exceptional_working_hours ewh
JOIN exceptional_working_hours_groups ewhg ON ewhg.id = ewh.group_id
@@ -591,7 +591,7 @@ func getClosingTime(r *http.Request, date time.Time) string {
// Fall back to default working hours
var defaultClose sql.NullString
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT end_time::text FROM working_hours WHERE weekday = $1 AND is_open = true
`, weekday).Scan(&defaultClose)
@@ -610,7 +610,7 @@ func fetchAppointment(r *http.Request, query string, args ...interface{}) (*Appo
var notes sql.NullString
var userID string
err := db.DB.QueryRow(r.Context(), query, args...).Scan(
err := db.Conn.QueryRow(r.Context(), query, args...).Scan(
&bookingID, &startTime, &status, &notes, &userID,
)
if err != nil {
@@ -633,7 +633,7 @@ func fetchAppointment(r *http.Request, query string, args ...interface{}) (*Appo
var email sql.NullString
var profilePicURL sql.NullString
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT id, fn, phone, email, profile_pic_url
FROM users
WHERE id = $1
@@ -652,7 +652,7 @@ func fetchAppointment(r *http.Request, query string, args ...interface{}) (*Appo
// Fetch unconsumed name history for former name display
var prevFirstName, prevLastName sql.NullString
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.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
@@ -670,7 +670,7 @@ func fetchAppointment(r *http.Request, query string, args ...interface{}) (*Appo
}
// Fetch services
serviceRows, err := db.DB.Query(r.Context(), `
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT
s.name,
s.description,
@@ -752,7 +752,7 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
now := time.Now()
// Auto-transition confirmed bookings that have started but not ended to in_progress
_, err := db.DB.Exec(r.Context(), `
_, err := db.Conn.Exec(r.Context(), `
UPDATE bookings
SET status = 'in_progress'
WHERE status = 'confirmed'
@@ -779,7 +779,7 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
}
// Auto-transition in_progress bookings that have ended to completed
_, err = db.DB.Exec(r.Context(), `
_, err = db.Conn.Exec(r.Context(), `
UPDATE bookings
SET status = 'completed'
WHERE status = 'in_progress'
@@ -808,7 +808,7 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
todayEnd := rangeStart.Add(24 * time.Hour)
// Fetch all bookings for today
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT
b.id,
b.start_time,
@@ -863,7 +863,7 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
Name string
Duration int
}
svcRows, err := db.DB.Query(r.Context(), `
svcRows, err := db.Conn.Query(r.Context(), `
SELECT bs.booking_id, s.name, COALESCE(bs.override_duration_minutes, s.duration_minutes)
FROM booking_services bs
LEFT JOIN services s ON bs.service_id = s.id
@@ -919,7 +919,7 @@ func GetTodayAppointmentsHandler(w http.ResponseWriter, r *http.Request) {
}
}
if len(userIDs) > 0 {
nhRows, err := db.DB.Query(r.Context(), `
nhRows, err := db.Conn.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
@@ -975,7 +975,7 @@ type PendingApprovalsResponse struct {
// GET /api/admin/today/pending-approvals
func GetPendingApprovalsHandler(w http.ResponseWriter, r *http.Request) {
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT
b.id,
b.start_time,
@@ -1021,7 +1021,7 @@ func GetPendingApprovalsHandler(w http.ResponseWriter, r *http.Request) {
// Batch-fetch name_history for all users.
prevByUser := make(map[string][2]string)
if len(userIDs) > 0 {
nhRows, err := db.DB.Query(r.Context(), `
nhRows, err := db.Conn.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
@@ -1042,7 +1042,7 @@ func GetPendingApprovalsHandler(w http.ResponseWriter, r *http.Request) {
svcMap := make(map[string][]string)
durMap := make(map[string]int)
if len(bookingIDs) > 0 {
svcRows, err := db.DB.Query(r.Context(), `
svcRows, err := db.Conn.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
+4 -4
View File
@@ -27,7 +27,7 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
var accountRole string
var profilePicURL sql.NullString
err := db.DB.QueryRow(r.Context(), `SELECT account_role, profile_pic_url FROM users WHERE id = $1`, userID).
err := db.Conn.QueryRow(r.Context(), `SELECT account_role, profile_pic_url FROM users WHERE id = $1`, userID).
Scan(&accountRole, &profilePicURL)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -60,7 +60,7 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
if payments.SquareClient != nil {
go func() {
rows, err := db.DB.Query(ctx,
rows, err := db.Conn.Query(context.Background(),
`SELECT square_card_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL`, userID)
if err != nil {
log.Printf("Warning: Failed to query saved cards for user %s: %v", userID, err)
@@ -83,14 +83,14 @@ func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
// --- SQL-level anonymization/deletion ---
if accountRole == "guest" {
_, err = db.DB.Exec(ctx, `SELECT delete_guest_user($1)`, userID)
_, err = db.Conn.Exec(ctx, `SELECT delete_guest_user($1)`, userID)
if err != nil {
log.Printf("Failed to delete guest user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
} else {
_, err = db.DB.Exec(ctx, `SELECT anonymize_user($1)`, userID)
_, err = db.Conn.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
log.Printf("Failed to anonymize user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
@@ -46,7 +46,7 @@ func GetCustomerRelationshipHandler(w http.ResponseWriter, r *http.Request) {
var lastVisit sql.NullTime
var exists bool
err := db.DB.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, userID).Scan(&exists)
err := db.Conn.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)`, userID).Scan(&exists)
if err != nil {
log.Printf("Failed to check user existence for %s: %v", userID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -57,7 +57,7 @@ func GetCustomerRelationshipHandler(w http.ResponseWriter, r *http.Request) {
return
}
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.payment_type IN ('full','partial','balance','deposit') AND p.payment_method != 'discount'), 0),
COALESCE(SUM(p.amount) FILTER (WHERE p.status = 'completed' AND p.payment_type IN ('full','partial','balance','deposit') AND p.payment_method = 'discount'), 0),
@@ -95,7 +95,7 @@ func GetCustomerRelationshipHandler(w http.ResponseWriter, r *http.Request) {
result.LastVisitDate = &lastVisitStr
}
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT name, cnt FROM (
SELECT s.name, COUNT(*) as count, COUNT(*) as cnt
FROM booking_services bsvc
+1 -1
View File
@@ -69,7 +69,7 @@ func GetGDPRExportHandler(w http.ResponseWriter, r *http.Request) {
go func() {
var result json.RawMessage
err := db.DB.QueryRow(context.Background(), `SELECT export_all_user_data($1)`, userID).Scan(&result)
err := db.Conn.QueryRow(context.Background(), `SELECT export_all_user_data($1)`, userID).Scan(&result)
if err != nil {
log.Printf("GDPR export failed for user %s: %v", userID, err)
gdprExportCacheMu.Lock()
+3 -3
View File
@@ -92,7 +92,7 @@ func CreateGuestUserHandler(w http.ResponseWriter, r *http.Request) {
// Check if email already exists with a registered (non-guest) role
var existingRole string
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT account_role FROM users WHERE email = $1
`, req.Email).Scan(&existingRole)
@@ -106,7 +106,7 @@ func CreateGuestUserHandler(w http.ResponseWriter, r *http.Request) {
// Create new guest user
var userID string
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
INSERT INTO users
(n_first_name, n_last_name, email, phone, date_of_birth,
account_role, account_type, password_hash, privacy_policy_and_terms_consent)
@@ -148,7 +148,7 @@ func CheckEmailHandler(w http.ResponseWriter, r *http.Request) {
phone := strings.TrimSpace(r.URL.Query().Get("phone"))
var dbFirstName, dbLastName, dbPhone *string
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT n_first_name, n_last_name, phone
FROM users
WHERE email = $1 AND account_role != 'guest'
+1 -1
View File
@@ -18,7 +18,7 @@ func GetLoyaltyHandler(w http.ResponseWriter, r *http.Request) {
userID, _ := mw.GetUserID(r.Context())
var loyalty LoyaltyResponse
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT loyalty_stamps, referral_code
FROM users
WHERE id = $1
+37 -35
View File
@@ -131,7 +131,7 @@ func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
}
var user UserProfile
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT
id, email, n_first_name, n_last_name, phone,
date_of_birth::text, account_role, loyalty_stamps,
@@ -154,7 +154,7 @@ func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
// Check if user has an unconsumed previous name (booking_id IS NULL means the name
// change hasn't been "seen" via a completed booking yet).
var prevFirstName, prevLastName sql.NullString
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT previous_first_name, previous_last_name
FROM name_history
WHERE user_id = $1 AND booking_id IS NULL
@@ -300,7 +300,7 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
var dob sql.NullTime
var profilePicURL sql.NullString
var currentFirstName, currentLastName string
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT email, date_of_birth, profile_pic_url, n_first_name, n_last_name FROM users WHERE id = $1
`, userID).Scan(&email, &dob, &profilePicURL, &currentFirstName, &currentLastName)
@@ -311,7 +311,7 @@ func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
}
// Use a transaction for atomicity: insert name history + update user
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "failed to begin transaction", http.StatusInternalServerError)
return
@@ -373,7 +373,7 @@ func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) {
// Fetch user details
var user AdminUserDetail
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT
id, email, n_first_name, n_last_name, fn, phone,
date_of_birth::text, profile_pic_url,
@@ -405,7 +405,7 @@ func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) {
}
// Fetch referral code uses count
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.QueryRow(r.Context(), `
SELECT COUNT(*)
FROM user_referrals
WHERE referrer_id = $1 AND claimed_booking_id IS NOT NULL
@@ -417,7 +417,7 @@ func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) {
// Fetch unconsumed name history (booking_id IS NULL = not yet "seen" via a completed booking)
var prevFirstName, prevLastName sql.NullString
err = db.DB.QueryRow(r.Context(), `
err = db.Conn.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
@@ -432,7 +432,7 @@ func GetAdminUserHandler(w http.ResponseWriter, r *http.Request) {
}
// Fetch social logins
socialRows, err := db.DB.Query(r.Context(), `
socialRows, err := db.Conn.Query(r.Context(), `
SELECT provider, created_at::text
FROM user_social_logins
WHERE user_id = $1
@@ -549,8 +549,22 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
listArgs = append(listArgs, perPage+1)
}
var users []UserListItem
var total int
// Compute total with a simple count query (no ORDER BY/LIMIT/HAVING).
// Run BEFORE the data query to avoid "conn busy" errors when routing
// through a per-test transaction (pgx.Tx does not support concurrent queries).
if searchTerm != "" {
db.Conn.QueryRow(r.Context(), `SELECT COUNT(DISTINCT u.id) FROM users u
LEFT JOIN bookings b ON u.id = b.user_id
WHERE u.fn ILIKE $1 OR u.email ILIKE $1 OR u.phone ILIKE $1`, "%"+searchTerm+"%").Scan(&total)
} else {
db.Conn.QueryRow(r.Context(), "SELECT COUNT(*) FROM users").Scan(&total)
}
// Get users list
rows, err := db.DB.Query(r.Context(), listQuery, listArgs...)
rows, err := db.Conn.Query(r.Context(), listQuery, listArgs...)
if err != nil {
log.Printf("Failed to fetch users: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -558,18 +572,6 @@ func ListAdminUsersHandler(w http.ResponseWriter, r *http.Request) {
}
defer rows.Close()
var users []UserListItem
var total int
// Compute total with a simple count query (no ORDER BY/LIMIT/HAVING).
if searchTerm != "" {
db.DB.QueryRow(r.Context(), `SELECT COUNT(DISTINCT u.id) FROM users u
LEFT JOIN bookings b ON u.id = b.user_id
WHERE u.fn ILIKE $1 OR u.email ILIKE $1 OR u.phone ILIKE $1`, "%"+searchTerm+"%").Scan(&total)
} else {
db.DB.QueryRow(r.Context(), "SELECT COUNT(*) FROM users").Scan(&total)
}
for rows.Next() {
var user UserListItem
var prevFirstName, prevLastName sql.NullString
@@ -678,7 +680,7 @@ func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) {
}
var passwordHash string
err := db.DB.QueryRow(r.Context(), `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&passwordHash)
err := db.Conn.QueryRow(r.Context(), `SELECT password_hash FROM users WHERE id = $1`, userID).Scan(&passwordHash)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "user not found", http.StatusNotFound)
@@ -701,7 +703,7 @@ func ChangePasswordHandler(w http.ResponseWriter, r *http.Request) {
return
}
_, err = db.DB.Exec(r.Context(), `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, string(newHash), userID)
_, err = db.Conn.Exec(r.Context(), `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, string(newHash), userID)
if err != nil {
log.Printf("Failed to update password for user %s: %v", userID, err)
http.Error(w, "failed to update password", http.StatusInternalServerError)
@@ -736,7 +738,7 @@ func GetEligiblePatchTestServicesHandler(w http.ResponseWriter, r *http.Request)
// Get services that require a patch test but user hasn't completed
// This queries patch_tests to find which services require patch tests,
// then excludes those the user already has valid records for
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT DISTINCT s.id, s.name, pt.id, pt.notice_duration_hours, pt.expiry_months
FROM services s
JOIN patch_tests pt ON s.id = ANY(pt.service_ids)
@@ -805,7 +807,7 @@ func AddPatchTestHandler(w http.ResponseWriter, r *http.Request) {
// Verify patch test exists
var patchTestID string
err := db.DB.QueryRow(r.Context(), `SELECT id FROM patch_tests WHERE id = $1`, req.PatchTestID).Scan(&patchTestID)
err := db.Conn.QueryRow(r.Context(), `SELECT id FROM patch_tests WHERE id = $1`, req.PatchTestID).Scan(&patchTestID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
http.Error(w, "patch test not found", http.StatusBadRequest)
@@ -817,7 +819,7 @@ func AddPatchTestHandler(w http.ResponseWriter, r *http.Request) {
}
// Insert or update user_patch_tests record
_, err = db.DB.Exec(r.Context(), `
_, err = db.Conn.Exec(r.Context(), `
INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at)
VALUES ($1, $2, NOW())
ON CONFLICT (user_id, patch_test_id) DO UPDATE SET tested_at = NOW()
@@ -849,7 +851,7 @@ func GetUserPatchTestsHandler(w http.ResponseWriter, r *http.Request) {
return
}
rows, err := db.DB.Query(r.Context(), `
rows, err := db.Conn.Query(r.Context(), `
SELECT upt.id, upt.patch_test_id, pt.name, upt.tested_at, upt.tested_at + (pt.expiry_months || ' months')::interval as valid_until
FROM user_patch_tests upt
JOIN patch_tests pt ON upt.patch_test_id = pt.id
@@ -891,7 +893,7 @@ func DeletePatchTestHandler(w http.ResponseWriter, r *http.Request) {
}
// testID in this context is the user_patch_tests.id (CHAR(12) hex)
result, err := db.DB.Exec(r.Context(), `
result, err := db.Conn.Exec(r.Context(), `
DELETE FROM user_patch_tests WHERE id = $1 AND user_id = $2
`, testID, userID)
if err != nil {
@@ -952,7 +954,7 @@ func UploadProfilePictureHandler(w http.ResponseWriter, r *http.Request) {
bucket := getEnv("S3_PROFILE_PICS_BUCKET", "crussell-profile-pics")
var oldURL sql.NullString
err = db.DB.QueryRow(r.Context(), `SELECT profile_pic_url FROM users WHERE id = $1`, userID).Scan(&oldURL)
err = db.Conn.QueryRow(r.Context(), `SELECT profile_pic_url FROM users WHERE id = $1`, userID).Scan(&oldURL)
if err == nil && oldURL.Valid && oldURL.String != "" {
if delErr := s3.Client.Delete(r.Context(), bucket, key); delErr != nil {
log.Printf("Warning: Failed to delete old profile picture for user %s: %v", userID, delErr)
@@ -979,7 +981,7 @@ func UploadProfilePictureHandler(w http.ResponseWriter, r *http.Request) {
return
}
_, err = db.DB.Exec(r.Context(), `UPDATE users SET profile_pic_url = $1 WHERE id = $2`, url, userID)
_, err = db.Conn.Exec(r.Context(), `UPDATE users SET profile_pic_url = $1 WHERE id = $2`, url, userID)
if err != nil {
log.Printf("Failed to update user profile pic: %v", err)
http.Error(w, "Failed to save profile picture", http.StatusInternalServerError)
@@ -1036,7 +1038,7 @@ func GetNotificationPreferencesHandler(w http.ResponseWriter, r *http.Request) {
}
var prefs NotificationPreferencesResponse
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT email_enabled, sms_enabled, browser_push_enabled
FROM user_notification_preferences
WHERE user_id = $1
@@ -1075,7 +1077,7 @@ func UpdateNotificationPreferencesHandler(w http.ResponseWriter, r *http.Request
}
var exists bool
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT EXISTS(SELECT 1 FROM user_notification_preferences WHERE user_id = $1)
`, userID).Scan(&exists)
if err != nil {
@@ -1085,7 +1087,7 @@ func UpdateNotificationPreferencesHandler(w http.ResponseWriter, r *http.Request
}
if exists {
_, err = db.DB.Exec(r.Context(), `
_, err = db.Conn.Exec(r.Context(), `
UPDATE user_notification_preferences SET
email_enabled = COALESCE($2, email_enabled),
sms_enabled = COALESCE($3, sms_enabled),
@@ -1094,7 +1096,7 @@ func UpdateNotificationPreferencesHandler(w http.ResponseWriter, r *http.Request
WHERE user_id = $1
`, userID, req.EmailEnabled, req.SMSEnabled, req.BrowserPushEnabled)
} else {
_, err = db.DB.Exec(r.Context(), `
_, err = db.Conn.Exec(r.Context(), `
INSERT INTO user_notification_preferences (user_id, email_enabled, sms_enabled, browser_push_enabled, updated_at)
VALUES ($1, COALESCE($2, true), COALESCE($3, true), COALESCE($4, true), NOW())
`, userID, req.EmailEnabled, req.SMSEnabled, req.BrowserPushEnabled)
@@ -1112,7 +1114,7 @@ func UpdateNotificationPreferencesHandler(w http.ResponseWriter, r *http.Request
// GET /api/contact
func GetContactInfoHandler(w http.ResponseWriter, r *http.Request) {
var contact ContactInfo
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT
COALESCE(n_first_name, '') || ' ' || COALESCE(n_last_name, '') as name,
COALESCE(phone, ''),
+7 -4
View File
@@ -56,11 +56,14 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
// Set SQUARE_WEBHOOK_SIGNATURE_KEY in production env vars from Square Developer Console.
// Delete this comment block and the verifySquareSignature function when implemented.
// TODO(PROD): Always verify signature before processing
signature := r.Header.Get("x-square-signature")
signingKey := os.Getenv("SQUARE_WEBHOOK_SIGNATURE_KEY")
if signingKey != "" && signature != "" {
// Dev-only stub — see top of function for production requirements
if signingKey != "" {
signature := r.Header.Get("x-square-signature")
if signature == "" {
log.Printf("Missing Square webhook signature header")
http.Error(w, "Invalid signature", http.StatusForbidden)
return
}
if !verifySquareSignature(body, signature, signingKey) {
log.Printf("Invalid Square webhook signature")
http.Error(w, "Invalid signature", http.StatusForbidden)
+2 -2
View File
@@ -81,7 +81,7 @@ func TestDevClient_CreateCheckout_PendingThenCompleted(t *testing.T) {
if err == nil && completed.Status == "COMPLETED" {
break
}
time.Sleep(5 * time.Millisecond)
time.Sleep(200 * time.Millisecond)
}
if err != nil {
t.Fatalf("GetCheckout failed: %v", err)
@@ -131,7 +131,7 @@ func TestDevClient_CreateCheckout_NoTip(t *testing.T) {
if err == nil && completed.Status == "COMPLETED" {
break
}
time.Sleep(5 * time.Millisecond)
time.Sleep(200 * time.Millisecond)
}
if err != nil {
t.Fatalf("GetCheckout failed: %v", err)
+2 -2
View File
@@ -107,8 +107,8 @@ func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
"frontend": "unknown",
}
if db.DB != nil {
if err := db.DB.Ping(r.Context()); err != nil {
if db.Conn != nil {
if err := db.Conn.Ping(r.Context()); err != nil {
services["database"] = "error"
status = "degraded"
}
+65 -64
View File
@@ -6,28 +6,29 @@ package fixtures
import (
"context"
"fmt"
"sync/atomic"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"crussell/db"
"golang.org/x/crypto/bcrypt"
)
// Global counter for unique emails in tests
var testEmailCounter int64
var testEmailCounter atomic.Int64
func CreateTestAdminUser(pool *pgxpool.Pool) (string, error) {
return createTestUser(pool, "Admin", "User", "", "admin")
func CreateTestAdminUser(q db.Querier) (string, error) {
return createTestUser(q, "Admin", "User", "", "admin")
}
func CreateTestUser(pool *pgxpool.Pool) (string, error) {
return createTestUser(pool, "Test", "User", "", "verified_email")
func CreateTestUser(q db.Querier) (string, error) {
return createTestUser(q, "Test", "User", "", "verified_email")
}
func CreateTestUserWithEmail(pool *pgxpool.Pool, email, role string) (string, error) {
return createTestUser(pool, "Test", "User", email, role)
func CreateTestUserWithEmail(q db.Querier, email, role string) (string, error) {
return createTestUser(q, "Test", "User", email, role)
}
func createTestUser(pool *pgxpool.Pool, firstName, lastName, email, role string) (string, error) {
func createTestUser(q db.Querier, firstName, lastName, email, role string) (string, error) {
passwordHash, err := bcrypt.GenerateFromPassword([]byte("testpassword123"), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("failed to hash password: %w", err)
@@ -35,13 +36,13 @@ func createTestUser(pool *pgxpool.Pool, firstName, lastName, email, role string)
// Generate unique email if not provided
if email == "" {
testEmailCounter++
email = fmt.Sprintf("%s.%s.%d@test.com", firstName, lastName, testEmailCounter)
n := testEmailCounter.Add(1)
email = fmt.Sprintf("%s.%s.%d@test.com", firstName, lastName, n)
}
ctx := context.Background()
var userID string
err = pool.QueryRow(ctx, `
err = q.QueryRow(ctx, `
INSERT INTO users (n_first_name, n_last_name, email, phone, date_of_birth, password_hash, account_role, account_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'email')
RETURNING id
@@ -54,10 +55,10 @@ func createTestUser(pool *pgxpool.Pool, firstName, lastName, email, role string)
return userID, nil
}
func CreateTestService(pool *pgxpool.Pool) (string, error) {
func CreateTestService(q db.Querier) (string, error) {
ctx := context.Background()
var serviceID string
err := pool.QueryRow(ctx, `
err := q.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
@@ -72,12 +73,12 @@ func CreateTestService(pool *pgxpool.Pool) (string, error) {
// CreateTestServiceWithPatchTest creates a service and a patch test that links to it
// Returns serviceID, patchTestID
func CreateTestServiceWithPatchTest(pool *pgxpool.Pool) (string, string, error) {
func CreateTestServiceWithPatchTest(q db.Querier) (string, string, error) {
ctx := context.Background()
// First create the service
var serviceID string
err := pool.QueryRow(ctx, `
err := q.QueryRow(ctx, `
INSERT INTO services (name, description, price, duration_minutes, is_active, minimum_age_required)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
@@ -89,7 +90,7 @@ func CreateTestServiceWithPatchTest(pool *pgxpool.Pool) (string, string, error)
// Now create a patch test that links to this service
var patchTestID string
err = pool.QueryRow(ctx, `
err = q.QueryRow(ctx, `
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
@@ -103,10 +104,10 @@ func CreateTestServiceWithPatchTest(pool *pgxpool.Pool) (string, string, error)
}
// CreateTestPatchTest creates a patch test definition
func CreateTestPatchTest(pool *pgxpool.Pool, serviceIDs []string) (string, error) {
func CreateTestPatchTest(q db.Querier, serviceIDs []string) (string, error) {
ctx := context.Background()
var patchTestID string
err := pool.QueryRow(ctx, `
err := q.QueryRow(ctx, `
INSERT INTO patch_tests (name, description, notice_duration_hours, expiry_months, service_ids)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
@@ -120,9 +121,9 @@ func CreateTestPatchTest(pool *pgxpool.Pool, serviceIDs []string) (string, error
}
// CreateUserPatchTest creates a user patch test record
func CreateUserPatchTest(pool *pgxpool.Pool, userID, patchTestID string, testedAt string) error {
func CreateUserPatchTest(q db.Querier, userID, patchTestID string, testedAt string) error {
ctx := context.Background()
_, err := pool.Exec(ctx, `
_, err := q.Exec(ctx, `
INSERT INTO user_patch_tests (user_id, patch_test_id, tested_at)
VALUES ($1, $2, $3)
`, userID, patchTestID, testedAt)
@@ -134,14 +135,14 @@ func CreateUserPatchTest(pool *pgxpool.Pool, userID, patchTestID string, testedA
return nil
}
func CreateTestBooking(pool *pgxpool.Pool, userID, serviceID string) (string, error) {
return CreateTestBookingAtTime(pool, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
func CreateTestBooking(q db.Querier, userID, serviceID string) (string, error) {
return CreateTestBookingAtTime(q, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
}
func CreateTestBookingAtTime(pool *pgxpool.Pool, userID, serviceID string, startTime time.Time) (string, error) {
func CreateTestBookingAtTime(q db.Querier, userID, serviceID string, startTime time.Time) (string, error) {
ctx := context.Background()
var bookingID string
err := pool.QueryRow(ctx, `
err := q.QueryRow(ctx, `
INSERT INTO bookings (user_id, start_time, status, notes)
VALUES ($1, $2, $3, $4)
RETURNING id
@@ -151,7 +152,7 @@ func CreateTestBookingAtTime(pool *pgxpool.Pool, userID, serviceID string, start
return "", fmt.Errorf("failed to create booking: %w", err)
}
_, err = pool.Exec(ctx, `
_, err = q.Exec(ctx, `
INSERT INTO booking_services (booking_id, service_id)
VALUES ($1, $2)
`, bookingID, serviceID)
@@ -163,40 +164,40 @@ func CreateTestBookingAtTime(pool *pgxpool.Pool, userID, serviceID string, start
return bookingID, nil
}
func CreateTestVerifiedUser(pool *pgxpool.Pool) (string, error) {
return createTestUser(pool, "Verified", "User", "verified@test.com", "verified_email")
func CreateTestVerifiedUser(q db.Querier) (string, error) {
return createTestUser(q, "Verified", "User", "verified@test.com", "verified_email")
}
func CreateTestUnverifiedUser(pool *pgxpool.Pool) (string, error) {
return createTestUser(pool, "Unverified", "User", "unverified@test.com", "unverified_email")
func CreateTestUnverifiedUser(q db.Querier) (string, error) {
return createTestUser(q, "Unverified", "User", "unverified@test.com", "unverified_email")
}
func CreateTestGuestUser(pool *pgxpool.Pool) (string, error) {
return createTestUser(pool, "Guest", "User", "guest@test.com", "guest")
func CreateTestGuestUser(q db.Querier) (string, error) {
return createTestUser(q, "Guest", "User", "guest@test.com", "guest")
}
func DeleteUser(pool *pgxpool.Pool, userID string) error {
func DeleteUser(q db.Querier, userID string) error {
ctx := context.Background()
_, err := pool.Exec(ctx, "DELETE FROM users WHERE id = $1", userID)
_, err := q.Exec(ctx, "DELETE FROM users WHERE id = $1", userID)
return err
}
func DeleteService(pool *pgxpool.Pool, serviceID string) error {
func DeleteService(q db.Querier, serviceID string) error {
ctx := context.Background()
_, err := pool.Exec(ctx, "DELETE FROM services WHERE id = $1", serviceID)
_, err := q.Exec(ctx, "DELETE FROM services WHERE id = $1", serviceID)
return err
}
func DeleteBooking(pool *pgxpool.Pool, bookingID string) error {
func DeleteBooking(q db.Querier, bookingID string) error {
ctx := context.Background()
_, err := pool.Exec(ctx, "DELETE FROM bookings WHERE id = $1", bookingID)
_, err := q.Exec(ctx, "DELETE FROM bookings WHERE id = $1", bookingID)
return err
}
func CreateTestCustomService(pool *pgxpool.Pool) (string, error) {
func CreateTestCustomService(q db.Querier) (string, error) {
ctx := context.Background()
var serviceID string
err := pool.QueryRow(ctx, `
err := q.QueryRow(ctx, `
INSERT INTO custom_services (name, description, price, duration_minutes, minimum_age_required)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
@@ -209,33 +210,33 @@ func CreateTestCustomService(pool *pgxpool.Pool) (string, error) {
return serviceID, nil
}
func DeleteCustomService(pool *pgxpool.Pool, serviceID string) error {
func DeleteCustomService(q db.Querier, serviceID string) error {
ctx := context.Background()
_, err := pool.Exec(ctx, "DELETE FROM custom_services WHERE id = $1", serviceID)
_, err := q.Exec(ctx, "DELETE FROM custom_services WHERE id = $1", serviceID)
return err
}
// SafeDeleteUser wraps DeleteUser and returns error (for tests that care about cleanup failure)
func SafeDeleteUser(db *pgxpool.Pool, userID string) error {
return DeleteUser(db, userID)
func SafeDeleteUser(q db.Querier, userID string) error {
return DeleteUser(q, userID)
}
// SafeDeleteService wraps DeleteService and returns error (for tests that care about cleanup failure)
func SafeDeleteService(db *pgxpool.Pool, serviceID string) error {
return DeleteService(db, serviceID)
func SafeDeleteService(q db.Querier, serviceID string) error {
return DeleteService(q, serviceID)
}
// SafeDeleteBooking wraps DeleteBooking and returns error (for tests that care about cleanup failure)
func SafeDeleteBooking(db *pgxpool.Pool, bookingID string) error {
return DeleteBooking(db, bookingID)
func SafeDeleteBooking(q db.Querier, bookingID string) error {
return DeleteBooking(q, bookingID)
}
// CreateTestTimeBlocker creates a time blocker for testing
// Returns the blocker ID
func CreateTestTimeBlocker(pool *pgxpool.Pool, startTime time.Time, durationMinutes int, description string) (string, error) {
func CreateTestTimeBlocker(q db.Querier, startTime time.Time, durationMinutes int, description string) (string, error) {
ctx := context.Background()
var blockerID string
err := pool.QueryRow(ctx, `
err := q.QueryRow(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description)
VALUES ($1, $2, $3)
RETURNING id
@@ -249,18 +250,18 @@ func CreateTestTimeBlocker(pool *pgxpool.Pool, startTime time.Time, durationMinu
}
// DeleteTimeBlocker removes a time blocker from the database
func DeleteTimeBlocker(pool *pgxpool.Pool, blockerID string) error {
func DeleteTimeBlocker(q db.Querier, blockerID string) error {
ctx := context.Background()
_, err := pool.Exec(ctx, "DELETE FROM time_blockers WHERE id = $1", blockerID)
_, err := q.Exec(ctx, "DELETE FROM time_blockers WHERE id = $1", blockerID)
return err
}
// CreateTestPayment creates a payment record for testing
// Returns payment ID
func CreateTestPayment(db *pgxpool.Pool, bookingID string, amount float64, method string, ptype string, status string) (string, error) {
func CreateTestPayment(q db.Querier, bookingID string, amount float64, method string, ptype string, status string) (string, error) {
ctx := context.Background()
var paymentID string
err := db.QueryRow(ctx, `
err := q.QueryRow(ctx, `
INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
RETURNING id
@@ -275,10 +276,10 @@ func CreateTestPayment(db *pgxpool.Pool, bookingID string, amount float64, metho
// CreateTestRefund creates a refund record for testing
// Returns refund ID
func CreateTestRefund(db *pgxpool.Pool, paymentID string, bookingID string, amount float64) (string, error) {
func CreateTestRefund(q db.Querier, paymentID string, bookingID string, amount float64) (string, error) {
ctx := context.Background()
var refundID string
err := db.QueryRow(ctx, `
err := q.QueryRow(ctx, `
INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at)
VALUES ($1, $2, $3, 'completed', 'test refund', NOW())
RETURNING id
@@ -293,10 +294,10 @@ func CreateTestRefund(db *pgxpool.Pool, paymentID string, bookingID string, amou
// CreateTestPaymentMethod creates a saved card for a user
// Returns card ID
func CreateTestPaymentMethod(db *pgxpool.Pool, userID string, squareCardID string, brand string, last4 string) (string, error) {
func CreateTestPaymentMethod(q db.Querier, userID string, squareCardID string, brand string, last4 string) (string, error) {
ctx := context.Background()
var cardID string
err := db.QueryRow(ctx, `
err := q.QueryRow(ctx, `
INSERT INTO user_saved_cards (user_id, square_card_id, brand, last_4, exp_month, exp_year, fingerprint, is_default, created_at)
VALUES ($1, $2, $3, $4, 12, 2030, 'test_fp', false, NOW())
RETURNING id
@@ -310,22 +311,22 @@ func CreateTestPaymentMethod(db *pgxpool.Pool, userID string, squareCardID strin
}
// DeletePayment deletes a payment from the database
func DeletePayment(pool *pgxpool.Pool, paymentID string) error {
func DeletePayment(q db.Querier, paymentID string) error {
ctx := context.Background()
_, err := pool.Exec(ctx, "DELETE FROM payments WHERE id = $1", paymentID)
_, err := q.Exec(ctx, "DELETE FROM payments WHERE id = $1", paymentID)
return err
}
// DeleteRefund deletes a refund from the database
func DeleteRefund(pool *pgxpool.Pool, refundID string) error {
func DeleteRefund(q db.Querier, refundID string) error {
ctx := context.Background()
_, err := pool.Exec(ctx, "DELETE FROM refunds WHERE id = $1", refundID)
_, err := q.Exec(ctx, "DELETE FROM refunds WHERE id = $1", refundID)
return err
}
// DeletePaymentMethod deletes a saved card from the database
func DeletePaymentMethod(pool *pgxpool.Pool, cardID string) error {
func DeletePaymentMethod(q db.Querier, cardID string) error {
ctx := context.Background()
_, err := pool.Exec(ctx, "DELETE FROM user_saved_cards WHERE id = $1", cardID)
_, err := q.Exec(ctx, "DELETE FROM user_saved_cards WHERE id = $1", cardID)
return err
}
+26 -29
View File
@@ -11,27 +11,20 @@ import (
"net/http/httptest"
"testing"
"crussell/db"
"crussell/mw"
"crussell/testutils/jwt"
"crussell/testutils/testdb"
"github.com/jackc/pgx/v5/pgxpool"
)
// SetupTestDB resets test data by truncating tables
// Assumes db.DB is already set by TestMain
func SetupTestDB(t *testing.T) func() {
t.Helper()
// contextKey matches mw.UserIDKey to avoid import cycle with auth package.
type contextKey string
testdb.TruncateTables(t, db.DB)
const userIDKey contextKey = "user_id"
return func() {}
}
// MakeRequest makes an HTTP request to a handler with optional JWT token
// token can be user token or admin token. Pass empty string for no auth.
func MakeRequest(handler http.Handler, method, path string, body interface{}, token string) *httptest.ResponseRecorder {
// MakeRequest makes an HTTP request to a handler with optional JWT token.
// ctx should carry a per-test transaction (from SetupTestTx) for PoolProxy routing.
func MakeRequest(handler http.Handler, method, path string, body interface{}, token string, ctx context.Context) *httptest.ResponseRecorder {
var req *http.Request
if body != nil {
bodyBytes, _ := json.Marshal(body)
@@ -41,6 +34,8 @@ func MakeRequest(handler http.Handler, method, path string, body interface{}, to
req = httptest.NewRequest(method, path, nil)
}
req = req.WithContext(ctx)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
@@ -68,25 +63,26 @@ func MakeRequestWithContext(handler http.Handler, method, path string, body inte
return w
}
// MakeUserRequest makes a request as an authenticated user
// Generates a valid user token and includes it in the Authorization header
func MakeUserRequest(handler http.Handler, method, path string, body interface{}, userID string) *httptest.ResponseRecorder {
// MakeUserRequest makes a request as an authenticated user.
// ctx should carry a per-test transaction (from SetupTestTx) for PoolProxy routing.
func MakeUserRequest(handler http.Handler, method, path string, body interface{}, userID string, ctx context.Context) *httptest.ResponseRecorder {
token := jwt.GenerateUserToken(userID)
return MakeRequest(handler, method, path, body, token)
return MakeRequest(handler, method, path, body, token, ctx)
}
// MakeAdminRequest makes a request as an authenticated admin
// Generates a valid admin token and includes it in the Authorization header
func MakeAdminRequest(handler http.Handler, method, path string, body interface{}, adminID string) *httptest.ResponseRecorder {
// MakeAdminRequest makes a request as an authenticated admin.
// ctx should carry a per-test transaction (from SetupTestTx) for PoolProxy routing.
func MakeAdminRequest(handler http.Handler, method, path string, body interface{}, adminID string, ctx context.Context) *httptest.ResponseRecorder {
token := jwt.GenerateTestToken(adminID, "admin")
return MakeRequest(handler, method, path, body, token)
return MakeRequest(handler, method, path, body, token, ctx)
}
// MakeContextRequest makes a request with user context set
// Useful for testing handlers that check context before validating token
func MakeContextRequest(handler http.Handler, method, path string, body interface{}, userID string) *httptest.ResponseRecorder {
ctx := context.WithValue(context.Background(), mw.UserIDKey, userID)
return MakeRequestWithContext(handler, method, path, body, ctx)
// MakeContextRequest makes a request with user context set.
// ctx should carry a per-test transaction (from SetupTestTx) for PoolProxy routing.
// The user ID is layered on top of the transaction context.
func MakeContextRequest(handler http.Handler, method, path string, body interface{}, userID string, ctx context.Context) *httptest.ResponseRecorder {
reqCtx := context.WithValue(ctx, userIDKey, userID)
return MakeRequestWithContext(handler, method, path, body, reqCtx)
}
// ParseResponseBody unmarshals the response body into dest
@@ -156,9 +152,10 @@ func GetBodyAsJSON(w *httptest.ResponseRecorder) (map[string]interface{}, error)
return result, err
}
// MakeRequestNoAuth makes an HTTP request without authentication (for testing unauthenticated endpoints)
func MakeRequestNoAuth(handler http.Handler, method, path string, body interface{}) *httptest.ResponseRecorder {
return MakeRequest(handler, method, path, body, "")
// MakeRequestNoAuth makes an HTTP request without authentication.
// ctx should carry a per-test transaction (from SetupTestTx) for PoolProxy routing.
func MakeRequestNoAuth(handler http.Handler, method, path string, body interface{}, ctx context.Context) *httptest.ResponseRecorder {
return MakeRequest(handler, method, path, body, "", ctx)
}
// AssertErrorStatusCode checks status code and validates error is in response body
+101 -36
View File
@@ -14,9 +14,16 @@ import (
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// Querier is a minimal interface satisfied by *db.PoolProxy, *pgxpool.Pool,
// and pgx.Tx. Defined locally to avoid an import cycle (db → testdb → db).
type Querier interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
const (
defaultTestDSN = "postgres://myuser:mypassword@localhost:5432/crussell_test?sslmode=disable"
adminDatabaseDSN = "postgres://myuser:mypassword@localhost:5432/mydb?sslmode=disable"
@@ -62,7 +69,13 @@ func CreateTestDatabase(dbName string) *pgxpool.Pool {
adminPool.Close()
testDSN := fmt.Sprintf("postgres://myuser:mypassword@localhost:5432/%s?sslmode=disable", dbName)
pool, err := pgxpool.New(ctx, testDSN)
poolCfg, err := pgxpool.ParseConfig(testDSN)
if err != nil {
log.Fatalf("testdb: failed to parse config: %v", err)
}
poolCfg.MaxConns = 16
poolCfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
pool, err := pgxpool.NewWithConfig(ctx, poolCfg)
if err != nil {
log.Fatalf("testdb: failed to connect to %s: %v", dbName, err)
}
@@ -170,6 +183,93 @@ func NewPool(dsn string) (*pgxpool.Pool, error) {
return pool, nil
}
// SeedBaseline populates the test database with reference data that is needed
// by most tests but never changes across test runs (working hours, business
// settings, etc.). Called once per TestMain AFTER migration but BEFORE m.Run().
// Data is committed at the pool level and visible inside all per-test transactions
// (PostgreSQL Read Committed isolation).
func SeedBaseline(pool *pgxpool.Pool) {
ctx := context.Background()
// Working hours: Mon-Sun 08:00-20:00, all open.
// Used by bookings, admin, and most handler tests that schedule appointments.
hours := []struct {
weekday int
startTime string
endTime string
isOpen bool
}{
{0, "08:00", "20:00", true},
{1, "08:00", "20:00", true},
{2, "08:00", "20:00", true},
{3, "08:00", "20:00", true},
{4, "08:00", "20:00", true},
{5, "08:00", "20:00", true},
{6, "08:00", "20:00", true},
}
for _, h := range hours {
_, err := pool.Exec(ctx, `
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
VALUES ($1, $2, $3, $4)
ON CONFLICT (weekday) DO UPDATE SET start_time = $2, end_time = $3, is_open = $4
`, h.weekday, h.startTime, h.endTime, h.isOpen)
if err != nil {
log.Fatalf("testdb: failed to seed working hours: %v", err)
}
}
// Business settings: required by admin/settings tests and booking deposit logic.
_, err := pool.Exec(ctx, `
INSERT INTO business_settings (business_name, business_address, currency_code, gift_card_expiry_months, voucher_type)
VALUES ('Test Salon', '123 Test St', 'GBP', 12, 'SPV')
ON CONFLICT DO NOTHING
`)
if err != nil {
log.Fatalf("testdb: failed to seed business settings: %v", err)
}
}
// SeedBaselineScheduling is like SeedBaseline but uses working hours appropriate
// for the scheduling package's tests (Mon-Fri 09:00-17:00, Sat 10:00-16:00, Sun closed).
func SeedBaselineScheduling(pool *pgxpool.Pool) {
ctx := context.Background()
hours := []struct {
weekday int
startTime string
endTime string
isOpen bool
}{
{0, "09:00", "17:00", true}, // Monday
{1, "09:00", "17:00", true}, // Tuesday
{2, "09:00", "17:00", true}, // Wednesday
{3, "09:00", "17:00", true}, // Thursday
{4, "09:00", "17:00", true}, // Friday
{5, "10:00", "16:00", true}, // Saturday
{6, "09:00", "17:00", false}, // Sunday (closed)
}
for _, h := range hours {
_, err := pool.Exec(ctx, `
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
VALUES ($1, $2, $3, $4)
ON CONFLICT (weekday) DO UPDATE SET start_time = $2, end_time = $3, is_open = $4
`, h.weekday, h.startTime, h.endTime, h.isOpen)
if err != nil {
log.Fatalf("testdb: failed to seed scheduling working hours: %v", err)
}
}
// Business settings same as baseline.
_, err := pool.Exec(ctx, `
INSERT INTO business_settings (business_name, business_address, currency_code, gift_card_expiry_months, voucher_type)
VALUES ('Test Salon', '123 Test St', 'GBP', 12, 'SPV')
ON CONFLICT DO NOTHING
`)
if err != nil {
log.Fatalf("testdb: failed to seed business settings: %v", err)
}
}
// migratePool executes the full schema migration on the given pool.
// This internal version returns errors directly for use by CreateTestDatabase.
func migratePool(pool *pgxpool.Pool) error {
@@ -287,41 +387,6 @@ func TxWithRollback(t *testing.T, pool *pgxpool.Pool) (pgx.Tx, func()) {
}
}
func TruncateTables(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
ctx := context.Background()
conn, err := pool.Acquire(ctx)
if err != nil {
t.Fatalf("Failed to acquire connection for truncation: %v", err)
}
defer conn.Release()
// Single TRUNCATE with all tables — one round-trip instead of ~44.
// CASCADE handles FK dependencies so order doesn't matter.
// Advisory lock removed: each package has its own database now.
_, err = conn.Exec(ctx, `
TRUNCATE TABLE
till_sales, admin_audit_log, gift_card_transactions,
gift_card_expired_balances, booking_discounts, loyalty_redemptions,
discount_campaigns, forgiven_no_shows, name_history, referral_discounts,
user_social_logins, verification_codes, booking_custom_services,
booking_services, refunds, payments, user_saved_cards, square_deposits,
affiliate_payouts, financial_aggregates, gift_cards, user_giftcard_balances,
bookings, booking_edit_requests, user_patch_tests, patch_tests, services,
custom_services, admin_notifications, user_referrals,
user_notification_preferences, time_blockers, working_hours,
exceptional_group_applications, exceptional_working_hours,
exceptional_working_hours_groups, business_settings, users, images, tags,
login_audit, refresh_tokens, revoked_jtis
CASCADE
`)
if err != nil {
t.Logf("Warning: truncation failed: %v", err)
}
}
func FindInitScript() (string, error) {
cwd, err := os.Getwd()
if err != nil {