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:
+14
-14
@@ -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
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¬es, &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, ¬es, &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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(¤tRole)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, ¤tStatus); err != nil {
|
||||
if err := db.Conn.QueryRow(r.Context(), "SELECT start_time, status FROM bookings WHERE id = $1", bookingID).Scan(&startTime, ¤tStatus); 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(¤tStatus); err != nil {
|
||||
if err := db.Conn.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2", bookingID, userID).Scan(¤tStatus); 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(¤tStatus, &bookingUserID, &startTime); err != nil {
|
||||
if err := db.Conn.QueryRow(r.Context(), "SELECT status, user_id, start_time FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus, &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
|
||||
|
||||
@@ -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(¤tStatus)
|
||||
err := db.Conn.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus)
|
||||
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(¤tStatus, ¤tStartTime, &depositRequired)
|
||||
err = db.Conn.QueryRow(r.Context(), "SELECT status, start_time, deposit_required FROM bookings WHERE id = $1", bookingID).Scan(¤tStatus, ¤tStartTime, &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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(¤tStatus, &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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¤tStatus)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¬es, &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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¤tFirstName, ¤tLastName)
|
||||
|
||||
@@ -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, ''),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user