refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns

Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

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-24 23:43:50 +01:00
co-authored by Sisyphus
parent 7b24f8e484
commit e4b9003439
36 changed files with 1923 additions and 590 deletions
+8 -7
View File
@@ -30,6 +30,7 @@ import (
"time"
"crussell/auth"
"crussell/clock"
"crussell/db"
"crussell/internal/dav"
"crussell/mw"
@@ -286,7 +287,7 @@ func TestRegister_InvalidInput_Under16(t *testing.T) {
handler := http.HandlerFunc(RegisterHandler)
// Calculate a date that makes them under 16
under16DOB := time.Now().AddDate(-15, 0, 0).Format("2006-01-02")
under16DOB := clock.Now().AddDate(-15, 0, 0).Format("2006-01-02")
body := RegisterRequest{
FirstName: "Young",
@@ -601,7 +602,7 @@ func TestVerifyCheck_ValidCode(t *testing.T) {
// Create a verification code
var code string
expiresAt := time.Now().Add(24 * time.Hour)
expiresAt := clock.Now().Add(24 * time.Hour)
err = tx.QueryRow(ctx,
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt).Scan(&code)
@@ -674,7 +675,7 @@ func TestVerifyCheck_ExpiredCode(t *testing.T) {
// Create an expired verification code
var code string
expiresAt := time.Now().Add(-1 * time.Hour) // Expired 1 hour ago
expiresAt := clock.Now().Add(-1 * time.Hour) // Expired 1 hour ago
err = tx.QueryRow(ctx,
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt).Scan(&code)
@@ -786,7 +787,7 @@ func TestVerifyCheck_AlreadyUsed(t *testing.T) {
// Create a verification code
var code string
expiresAt := time.Now().Add(24 * time.Hour)
expiresAt := clock.Now().Add(24 * time.Hour)
err = tx.QueryRow(ctx,
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt).Scan(&code)
@@ -840,7 +841,7 @@ func TestVerifyCheck_RoleChangeToVerified(t *testing.T) {
// Create a verification code
var code string
expiresAt := time.Now().Add(24 * time.Hour)
expiresAt := clock.Now().Add(24 * time.Hour)
err = tx.QueryRow(ctx,
`INSERT INTO verification_codes (user_id, purpose, expires_at) VALUES ($1, 'email_verify', $2) RETURNING code`,
userID, expiresAt).Scan(&code)
@@ -1446,7 +1447,7 @@ func TestLoginInProgress_Cap(t *testing.T) {
// Fill the loginInProgress map with 20 entries
loginStateMu.Lock()
for i := 0; i < maxLoginInProgress; i++ {
loginInProgress[fmt.Sprintf("stale-user-%d", i)] = time.Now()
loginInProgress[fmt.Sprintf("stale-user-%d", i)] = clock.Now()
}
loginStateMu.Unlock()
@@ -1736,7 +1737,7 @@ func TestJTI_Revocation_PostgreSQL(t *testing.T) {
t.Fatalf("token should be valid before revocation: %v", err)
}
auth.RevokeJTI(ctx, jti, time.Now().Add(1*time.Hour))
auth.RevokeJTI(ctx, jti, clock.Now().Add(1*time.Hour))
if !auth.IsJTIRevoked(ctx, jti) {
t.Error("JTI should be revoked after RevokeJTI call")
+69 -24
View File
@@ -2,6 +2,7 @@ package auth
import (
"crussell/auth"
"crussell/clock"
"crussell/db"
"github.com/jackc/pgx/v5"
"crussell/internal/dav"
@@ -44,15 +45,22 @@ func init() {
defer ticker.Stop()
for range ticker.C {
loginStateMu.Lock()
now := time.Now()
// Clean up stuck loginInProgress entries (older than 30s)
for userID, startedAt := range loginInProgress {
if now.Sub(startedAt) > 30*time.Second {
delete(loginInProgress, userID)
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic recovered in login state cleanup ticker: %v", r)
}
}()
loginStateMu.Lock()
now := clock.Now()
// Clean up stuck loginInProgress entries (older than 30s)
for userID, startedAt := range loginInProgress {
if now.Sub(startedAt) > 30*time.Second {
delete(loginInProgress, userID)
}
}
}
loginStateMu.Unlock()
loginStateMu.Unlock()
}()
}
}()
}
@@ -183,7 +191,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
}
// Reject if younger than 16
if !dob.Before(time.Now().AddDate(-16, 0, 0)) {
if !dob.Before(clock.Now().AddDate(-16, 0, 0)) {
http.Error(w, "account creation prohibited for users under 16. Please call to book an appointment.", http.StatusBadRequest)
return
}
@@ -224,7 +232,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
}
defer tx.Rollback(r.Context())
now := time.Now()
now := clock.Now()
// Insert and return the generated ID
var userID string
@@ -266,6 +274,11 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) {
}
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("Panic recovered in CardDAV contact creation: %v", r)
}
}()
input := dav.ContactInput{
UserID: userID,
FirstName: req.FirstName,
@@ -334,7 +347,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
var failedAttempts int
var lockedUntil *time.Time
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) {
if err == nil && lockedUntil != nil && clock.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()))
return
@@ -353,7 +366,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "server busy, try again later", http.StatusTooManyRequests)
return
}
loginInProgress[userID] = time.Now()
loginInProgress[userID] = clock.Now()
loginStateMu.Unlock()
// Always clear flag when done
@@ -368,7 +381,15 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
// Increment failed attempts in DB with progressive lockout
var newFailed int
var newLockedUntil *time.Time
db.Conn.QueryRow(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)
return
}
defer tx.Rollback(r.Context())
err = tx.QueryRow(r.Context(), `
UPDATE users
SET failed_attempts = failed_attempts + 1,
locked_until = CASE
@@ -383,6 +404,17 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
WHERE id = $1
RETURNING failed_attempts, locked_until
`, userID).Scan(&newFailed, &newLockedUntil)
if err != nil {
log.Printf("Failed to update failed login attempts: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
log.Printf("LOGIN_AUDIT: failed login user=%s ip=%s attempts=%d locked_until=%v",
userID, middleware.GetClientIP(r.Context()), newFailed, newLockedUntil)
@@ -394,7 +426,26 @@ 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.Conn.Exec(r.Context(), `UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = $1`, userID)
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)
return
}
defer tx.Rollback(r.Context())
_, err = tx.Exec(r.Context(), `UPDATE users SET failed_attempts = 0, locked_until = NULL, last_login_at = NOW() WHERE id = $1`, userID)
if err != nil {
log.Printf("Failed to reset login attempts on success: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit transaction: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Generate JWT
tokenString, jti, err := auth.GenerateToken(userID, role)
@@ -411,7 +462,6 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(auth.AuthResponse{
Token: tokenString,
JTI: jti,
@@ -443,7 +493,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(r.Context(), oldJTI, time.Now().Add(90*24*time.Hour)) // match refresh token lifetime
auth.RevokeJTI(r.Context(), oldJTI, clock.Now().Add(90*24*time.Hour)) // match refresh token lifetime
}
// Generate new token
@@ -461,7 +511,6 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(auth.AuthResponse{
Token: newToken,
JTI: jti,
@@ -478,9 +527,8 @@ func LogoutHandler(w http.ResponseWriter, r *http.Request) {
}
// Revoke the JTI — match the access token lifetime (1 hour)
auth.RevokeJTI(r.Context(), jti, time.Now().Add(1*time.Hour))
auth.RevokeJTI(r.Context(), jti, clock.Now().Add(1*time.Hour))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
@@ -520,7 +568,6 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
).Scan(&userID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "If the email exists, a verification code will be sent"})
return
}
@@ -529,7 +576,7 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
expiresAt := time.Now().Add(24 * time.Hour)
expiresAt := clock.Now().Add(24 * time.Hour)
var code string
err = db.Conn.QueryRow(r.Context(),
@@ -542,7 +589,6 @@ func GenerateVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Verification code generated"})
}
@@ -634,7 +680,6 @@ func VerifyCodeHandler(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(VerificationResponse{Success: true, Message: "Email verified successfully"})
}
@@ -643,7 +688,7 @@ func generateSecureCode(length int) string {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
log.Printf("Failed to generate random code: %v", err)
return strings.ToLower(fmt.Sprintf("%x", time.Now().UnixNano()))
return strings.ToLower(fmt.Sprintf("%x", clock.Now().UnixNano()))
}
return strings.ToLower(fmt.Sprintf("%x", bytes))
}