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

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

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

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-21 19:28:54 +01:00
co-authored by Sisyphus
parent c69a243f75
commit 3d0e2afc4c
39 changed files with 751 additions and 638 deletions
+22 -25
View File
@@ -1,7 +1,6 @@
package auth
import (
"context"
"crussell/auth"
"crussell/db"
"github.com/jackc/pgx/v5"
@@ -29,9 +28,7 @@ import (
"golang.org/x/text/language"
)
var (
titleCaser = cases.Title(language.English)
)
const maxLoginInProgress = 20
@@ -173,8 +170,10 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
req.Phone = strings.TrimSpace(phone)
// Convert names to title case
req.FirstName = titleCaser.String(strings.ToLower(req.FirstName))
req.LastName = titleCaser.String(strings.ToLower(req.LastName))
// Create per-call caser (cases.Caser is not goroutine-safe)
tc := cases.Title(language.English)
req.FirstName = tc.String(strings.ToLower(req.FirstName))
req.LastName = tc.String(strings.ToLower(req.LastName))
// Parse date of birth
dob, err := time.Parse("2006-01-02", req.DateOfBirth)
@@ -203,7 +202,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Look up referrer by referral code
err := db.DB.QueryRow(r.Context(),
err := db.Conn.QueryRow(r.Context(),
"SELECT id FROM users WHERE referral_code = $1", req.ReferralCode).Scan(&referrerID)
if err != nil {
http.Error(w, "invalid referral code", http.StatusBadRequest)
@@ -218,7 +217,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "server error", http.StatusInternalServerError)
return
@@ -319,8 +318,8 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
var userID, passwordHash, role string
ctx := context.Background()
err := db.DB.QueryRow(ctx, `
ctx := r.Context()
err := db.Conn.QueryRow(ctx, `
SELECT id, password_hash, account_role
FROM users
WHERE email = $1 AND account_type = 'email'
@@ -334,7 +333,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
// Check if account is locked
var failedAttempts int
var lockedUntil *time.Time
err = db.DB.QueryRow(r.Context(), `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil)
err = db.Conn.QueryRow(r.Context(), `SELECT failed_attempts, locked_until FROM users WHERE id = $1`, userID).Scan(&failedAttempts, &lockedUntil)
if err == nil && lockedUntil != nil && time.Now().Before(*lockedUntil) {
http.Error(w, "account is temporarily locked. try again later.", http.StatusTooManyRequests)
log.Printf("LOGIN_AUDIT: locked account attempt - user=%s ip=%s", userID, middleware.GetClientIP(r.Context()))
@@ -369,7 +368,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
// Increment failed attempts in DB with progressive lockout
var newFailed int
var newLockedUntil *time.Time
db.DB.QueryRow(r.Context(), `
db.Conn.QueryRow(r.Context(), `
UPDATE users
SET failed_attempts = failed_attempts + 1,
locked_until = CASE
@@ -395,7 +394,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
// On success, clear lockout and update last_login
// TODO: Password reset flow (MVP #4 in Future Work doc) must also clear
// failed_attempts and locked_until — a locked-out user can't call this handler.
db.DB.Exec(r.Context(), `UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = $1`, userID)
db.Conn.Exec(r.Context(), `UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = $1`, userID)
// Generate JWT
tokenString, jti, err := auth.GenerateToken(userID, role)
@@ -405,7 +404,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
}
// Generate refresh token for token rotation
refreshToken, err := auth.GenerateRefreshToken(userID, role)
refreshToken, err := auth.GenerateRefreshToken(r.Context(), userID, role)
if err != nil {
log.Printf("Failed to generate refresh token: %v", err)
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
@@ -428,7 +427,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
// Verify user still exists and role hasn't changed
var currentRole string
err := db.DB.QueryRow(r.Context(), `
err := db.Conn.QueryRow(r.Context(), `
SELECT account_role FROM users WHERE id = $1
`, userID).Scan(&currentRole)
@@ -444,7 +443,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
// Revoke the old token's JTI before issuing a new one (rotation)
if oldJTI != "" {
auth.RevokeJTI(oldJTI, time.Now().Add(90*24*time.Hour)) // match refresh token lifetime
auth.RevokeJTI(r.Context(), oldJTI, time.Now().Add(90*24*time.Hour)) // match refresh token lifetime
}
// Generate new token
@@ -455,7 +454,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
}
// Also issue a refresh token (opaque, stored in DB)
refreshToken, err := auth.GenerateRefreshToken(userID, currentRole)
refreshToken, err := auth.GenerateRefreshToken(r.Context(), userID, currentRole)
if err != nil {
log.Printf("Failed to generate refresh token: %v", err)
http.Error(w, "could not generate refresh token", http.StatusInternalServerError)
@@ -479,7 +478,7 @@ func LogoutHandler(w http.ResponseWriter, r *http.Request) {
}
// Revoke the JTI — match the access token lifetime (1 hour)
auth.RevokeJTI(jti, time.Now().Add(1*time.Hour))
auth.RevokeJTI(r.Context(), jti, time.Now().Add(1*time.Hour))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"success": true})
@@ -516,7 +515,7 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
}
var userID string
err := db.DB.QueryRow(r.Context(),
err := db.Conn.QueryRow(r.Context(),
"SELECT id FROM users WHERE LOWER(email) = $1", email,
).Scan(&userID)
if err != nil {
@@ -533,7 +532,7 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
expiresAt := time.Now().Add(24 * time.Hour)
var code string
err = db.DB.QueryRow(r.Context(),
err = db.Conn.QueryRow(r.Context(),
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt,
).Scan(&code)
@@ -543,8 +542,6 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("DEBUG: Verification code for %s: %s (expires at %s)", email, code, expiresAt.Format(time.RFC3339))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Verification code generated"})
}
@@ -570,7 +567,7 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
var purpose string
var expiresAt time.Time
err := db.DB.QueryRow(r.Context(),
err := db.Conn.QueryRow(r.Context(),
`SELECT user_id, purpose, expires_at FROM verification_codes
WHERE code = $1 AND used_at IS NULL AND expires_at > NOW()`,
code,
@@ -579,7 +576,7 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
if errors.Is(err, pgx.ErrNoRows) {
// Check if code exists but was already used or expired
var checkUsedAt *time.Time
checkErr := db.DB.QueryRow(r.Context(),
checkErr := db.Conn.QueryRow(r.Context(),
`SELECT used_at FROM verification_codes WHERE code = $1`, code,
).Scan(&checkUsedAt)
if checkErr != nil {
@@ -601,7 +598,7 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
tx, err := db.DB.Begin(r.Context())
tx, err := db.Conn.Begin(r.Context())
if err != nil {
log.Printf("Failed to start transaction: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)