aurganize-backend/backend/internal/repositories/user_repository.go

843 lines
26 KiB
Go

package repositories
import (
"context"
"database/sql"
"fmt"
"github.com/creativenoz/aurganize-v62/backend/internal/models"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
)
// UserRepository handles all database operations related to users.
// This is the data access layer for user management.
//
// What this repository does:
// - Creates new users (registration)
// - Finds users by email or ID (login, profile lookup)
// - Updates user information (password, last login)
// - Verifies passwords (authentication)
// - Checks email uniqueness (prevent duplicates)
//
// Architecture pattern: Repository Pattern
// Benefits:
// - Separates database logic from business logic
// - Makes testing easier (can mock repository)
// - Provides clean interface for data access
// - Centralizes SQL queries
// - Makes it easy to change database later
//
// Security considerations:
// - Passwords are NEVER stored in plaintext
// - Always use bcrypt for password hashing
// - Soft deletes (deleted_at) preserve data integrity
// - Email normalization prevents duplicate accounts
type UserRepository struct {
db *sqlx.DB // sqlx provides enhanced database operations (named params, struct scanning)
}
// NewUserRepository creates a new instance of UserRepository.
// This follows dependency injection pattern:
// - Database connection passed in (not created internally)
// - Makes testing easier (can inject test database)
// - Keeps repository decoupled from connection setup
// - Follows SOLID principles (Dependency Inversion)
//
// Parameter:
// - db: The database connection pool for all operations
//
// Returns:
// - Initialized UserRepository ready for use
func NewUserRepository(db *sqlx.DB) *UserRepository {
log.Info().
Str("repository", "user").
Str("component", "repository_init").
Bool("has_db_connection", db != nil).
Msg("user repository initialized")
return &UserRepository{db: db}
}
// Create creates a new user in the database.
// This is called during user registration.
//
// What happens:
// 1. Hash password using bcrypt (NEVER store plaintext!)
// 2. Insert user record with hashed password
// 3. Database generates ID, timestamps, and computed fields (full_name)
// 4. Return complete user object
//
// Why bcrypt for passwords?
// - Specifically designed for password hashing
// - Slow by design (resists brute-force attacks)
// - Includes salt automatically (prevents rainbow table attacks)
// - Adaptive (can increase cost factor as computers get faster)
// - Industry standard for password storage
//
// bcrypt workflow:
// 1. Generates random salt
// 2. Combines password + salt
// 3. Hashes multiple times (cost factor determines iterations)
// 4. Result: "$2a$10$salt+hash" format (self-contained, includes cost and salt)
//
// Why DefaultCost?
// - Balance between security and performance
// - DefaultCost = 10 (2^10 = 1024 iterations)
// - Takes ~100ms to hash (acceptable for login, but slows brute-force)
// - Can increase for higher security (cost 12 = 4x slower, cost 14 = 16x slower)
//
// Database features used:
// - RETURNING clause: Get back created record without separate SELECT
// - Auto-generated fields: id (UUID), timestamps, full_name (computed)
//
// Error handling:
// - Returns error if password hashing fails (very rare)
// - Returns error if insert fails (constraint violations, etc.)
//
// Flow:
// 1. Hash password with bcrypt
// 2. Execute INSERT with RETURNING
// 3. Scan returned row into user struct
// 4. Return populated user object
func (r *UserRepository) Create(ctx context.Context, input *models.CreateUserInput) (*models.User, error) {
log.Info().
Str("repository", "user").
Str("action", "create_user_started").
Str("tenant_id", input.TenantID.String()).
Str("email", input.Email).
Str("role", input.Role).
Str("status", input.Status).
Bool("has_first_name", input.FirstName != nil).
Bool("has_last_name", input.LastName != nil).
Msg("creating new user")
// Step 1: Hash the password using bcrypt
// bcrypt.GenerateFromPassword:
// - Takes password as []byte
// - Takes cost factor (DefaultCost = 10)
// - Returns hash as []byte (e.g., "$2a$10$...")
// - Returns error if hashing fails (very rare, maybe out of memory)
log.Debug().
Str("repository", "user").
Str("action", "hashing_password").
Int("bcrypt_cost", bcrypt.DefaultCost).
Msg("hashing user password with bcrypt")
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
log.Error().
Str("repository", "user").
Str("action", "password_hashing_failed").
Str("email", input.Email).
Int("bcrypt_cost", bcrypt.DefaultCost).
Err(err).
Msg("CRITICAL: failed to hash password with bcrypt")
// Wrap error with context for better debugging
return nil, fmt.Errorf("failed to hash password : %w", err)
}
log.Debug().
Str("repository", "user").
Str("action", "password_hashed").
Msg("password hashed successfully")
// Prepare user struct to receive database response
user := &models.User{}
// SQL INSERT query with RETURNING clause
// PostgreSQL returns the inserted row, avoiding a separate SELECT
// This is atomic and more efficient
query := `
INSERT INTO users (
tenant_id, -- Multi-tenancy: which organization user belongs to
email, -- User's email (unique identifier for login)
password_hash, -- Bcrypt hash of password (NEVER plaintext!)
first_name, -- User's first name
last_name, -- User's last name
role, -- User's role (admin, user, manager, etc.)
status -- Account status (active, pending, suspended, deleted)
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id,tenant_id, email, password_hash, first_name, last_name,
full_name, avatar_url, phone, role, status, email_verified,
email_verified_at, is_onboarded, last_login_at, last_login_ip,
created_at, updated_at, deleted_at
`
// Execute query and scan result into user struct
// GetContext:
// - Supports context (cancellation, timeout)
// - Expects exactly one row
// - Maps columns to struct fields by db tags
err = r.db.GetContext(
ctx,
user, // Destination struct
query,
// Parameters matching $1-$7 in query
input.TenantID,
input.Email,
string(hashedPassword), // Convert []byte to string for database
input.FirstName,
input.LastName,
input.Role,
input.Status,
)
if err != nil {
log.Error().
Str("repository", "user").
Str("action", "create_user_failed").
Str("tenant_id", input.TenantID.String()).
Str("email", input.Email).
Str("role", input.Role).
Err(err).
Msg("failed to create user in database")
return nil, err
}
log.Info().
Str("repository", "user").
Str("action", "create_user_success").
Str("user_id", user.ID.String()).
Str("tenant_id", user.TenantID.String()).
Str("email", user.Email).
Str("role", user.Role).
Str("status", user.Status).
Str("full_name", user.FullName).
Bool("email_verified", user.EmailVerified).
Msg("user created successfully")
return user, err
}
// CreateTx creates a new user within an existing transaction.
// This is used during registration to ensure tenant and user are created atomically.
//
// Why this exists:
// - Registration creates tenant + user in one transaction
// - User INSERT has FK constraint to tenants.id
// - FK check must run within the same transaction to see uncommitted tenant
// - Using r.db.GetContext() would use a different session (FK validation fails)
// - Using tx.GetContext() keeps everything in the same transaction
//
// Parameters:
// - ctx: Context for cancellation/timeout
// - tx: Transaction object (implements Execer interface)
// - input: User creation data
//
// Returns:
// - (*User, nil): User created successfully within transaction
// - (nil, error): Failed (password hashing or database error)
func (r *UserRepository) CreateTx(ctx context.Context, tx Execer, input *models.CreateUserInput) (*models.User, error) {
log.Info().
Str("repository", "user").
Str("action", "create_user_in_transaction").
Str("tenant_id", input.TenantID.String()).
Str("email", input.Email).
Str("role", input.Role).
Bool("in_transaction", true).
Msg("creating new user within transaction")
// Hash password (same as Create method)
log.Debug().
Str("repository", "user").
Str("action", "hashing_password").
Int("bcrypt_cost", bcrypt.DefaultCost).
Msg("hashing user password with bcrypt")
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
log.Error().
Str("repository", "user").
Str("action", "password_hashing_failed").
Str("email", input.Email).
Err(err).
Msg("CRITICAL: failed to hash password with bcrypt in transaction")
return nil, fmt.Errorf("failed to hash password: %w", err)
}
log.Debug().
Str("repository", "user").
Str("action", "password_hashed").
Msg("password hashed successfully")
user := &models.User{}
query := `
INSERT INTO users (
tenant_id,
email,
password_hash,
first_name,
last_name,
role,
status
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, tenant_id, email, password_hash, first_name, last_name,
full_name, avatar_url, phone, role, status, email_verified,
email_verified_at, is_onboarded, last_login_at, last_login_ip,
created_at, updated_at, deleted_at
`
// ✅ CRITICAL: Use tx.GetContext() to stay within transaction
err = tx.GetContext(
ctx,
user,
query,
input.TenantID,
input.Email,
string(hashedPassword),
input.FirstName,
input.LastName,
input.Role,
input.Status,
)
if err != nil {
log.Error().
Str("repository", "user").
Str("action", "create_user_in_transaction_failed").
Str("tenant_id", input.TenantID.String()).
Str("email", input.Email).
Bool("in_transaction", true).
Err(err).
Msg("failed to create user within transaction")
return nil, err
}
log.Info().
Str("repository", "user").
Str("action", "create_user_in_transaction_success").
Str("user_id", user.ID.String()).
Str("tenant_id", user.TenantID.String()).
Str("email", user.Email).
Str("role", user.Role).
Bool("in_transaction", true).
Msg("user created successfully within transaction")
return user, nil
}
// FindByEmail finds a user by their email address.
// This is used during login and email existence checks.
//
// Why search by email?
// - Email is the unique identifier for login
// - Users remember emails better than IDs
// - Standard practice for web applications
//
// Security considerations:
// - Email comparison is case-sensitive in database
// - Service layer should normalize email (lowercase, trim)
// - Prevents duplicate accounts with different casing
//
// Soft delete handling:
// - Only returns users where deleted_at IS NULL
// - Deleted users are hidden but data preserved
// - Allows for account recovery
// - Maintains referential integrity
//
// Return values:
// - (*User, nil): User found
// - (nil, nil): User not found (not an error, just doesn't exist)
// - (nil, error): Database error occurred
//
// Why return nil instead of error when not found?
// - "Not found" is a valid state, not an error
// - Allows caller to distinguish between "doesn't exist" and "database error"
// - Follows repository pattern best practices
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*models.User, error) {
log.Debug().
Str("repository", "user").
Str("action", "find_user_by_email_started").
Str("email", email).
Msg("looking up user by email")
user := &models.User{}
// SQL SELECT query
// Note: Should add LIMIT 1 for optimization (early exit)
query := `
SELECT id,tenant_id, email, password_hash, first_name, last_name,
full_name, avatar_url, phone, role, status, email_verified,
email_verified_at, is_onboarded, last_login_at, last_login_ip,
created_at, updated_at, deleted_at
FROM users
WHERE email = $1 -- Exact email match
AND deleted_at IS NULL -- Only non-deleted users
`
// Execute query
err := r.db.GetContext(
ctx,
user,
query,
email, // $1 - Email to search for
)
// Handle "not found" case specially
// sql.ErrNoRows means query executed but returned no rows
// This is expected when user doesn't exist
if err == sql.ErrNoRows {
log.Warn().
Str("repository", "user").
Str("action", "user_not_found_by_email").
Str("email", email).
Msg("user not found by email")
return nil, nil // User not found (not an error)
}
if err != nil {
log.Error().
Str("repository", "user").
Str("action", "find_user_by_email_error").
Str("email", email).
Err(err).
Msg("database error while looking up user by email")
return nil, err // Real database error
}
log.Debug().
Str("repository", "user").
Str("action", "user_found_by_email").
Str("user_id", user.ID.String()).
Str("email", user.Email).
Str("tenant_id", user.TenantID.String()).
Str("role", user.Role).
Str("status", user.Status).
Bool("email_verified", user.EmailVerified).
Msg("user found by email")
return user, nil
}
// FindByID finds a user by their unique ID.
// This is used for:
// - Loading user after authentication
// - Fetching user profile
// - Validating user existence
// - Retrieving user for operations
//
// Why search by ID vs email?
// - ID lookup is faster (primary key index)
// - ID never changes (email might change)
// - Used internally after user is identified
//
// When to use ID vs email:
// - Use email: Login, registration checks
// - Use ID: After authentication, internal operations
//
// Soft delete handling:
// - Only returns non-deleted users (deleted_at IS NULL)
// - Prevents access to deleted accounts
// - Maintains data for audit trail
//
// Return values:
// - (*User, nil): User found
// - (nil, nil): User not found or deleted
// - (nil, error): Database error occurred
func (r *UserRepository) FindByID(ctx context.Context, id uuid.UUID) (*models.User, error) {
log.Debug().
Str("repository", "user").
Str("action", "find_user_by_id_started").
Str("user_id", id.String()).
Msg("looking up user by id")
user := &models.User{}
// SQL SELECT query by ID
query := `
SELECT id,tenant_id, email, password_hash, first_name, last_name,
full_name, avatar_url, phone, role, status, email_verified,
email_verified_at, is_onboarded, last_login_at, last_login_ip,
created_at, updated_at, deleted_at
FROM users
WHERE id = $1 -- Exact ID match (UUID)
AND deleted_at IS NULL -- Only non-deleted users
`
// Execute query
err := r.db.GetContext(
ctx,
user,
query,
id, // $1 - User ID (UUID)
)
// Handle "not found" case
if err == sql.ErrNoRows {
log.Warn().
Str("repository", "user").
Str("action", "user_not_found_by_id").
Str("user_id", id.String()).
Msg("user not found by id - may be deleted")
return nil, nil // User not found (not an error)
}
if err != nil {
log.Debug().
Str("repository", "user").
Str("action", "user_found_by_id").
Str("user_id", user.ID.String()).
Str("email", user.Email).
Str("tenant_id", user.TenantID.String()).
Str("role", user.Role).
Str("status", user.Status).
Msg("user found by id")
return nil, err // Database error
}
return user, nil
}
// EmailExists checks if an email is already registered in the system.
// This is used during registration to prevent duplicate accounts.
//
// Why check email existence?
// 1. Prevent duplicate accounts (UX issue)
// 2. Provide clear error messages ("Email already registered")
// 3. Enforce uniqueness at application level (in addition to database constraint)
// 4. Allow custom error handling (e.g., suggest login instead)
//
// Implementation using EXISTS:
// - EXISTS is efficient (stops at first match)
// - Returns boolean directly
// - Doesn't load full user data (faster than COUNT or SELECT)
// - Uses index on email column
//
// Soft delete consideration:
// - Only checks non-deleted users (deleted_at IS NULL)
// - Allows email reuse after deletion (debatable design choice)
// - Alternative: Never allow email reuse (more strict)
//
// Return values:
// - (true, nil): Email exists (already registered)
// - (false, nil): Email available (can register)
// - (false, error): Database error occurred
func (r *UserRepository) EmailExists(ctx context.Context, email string) (bool, error) {
log.Debug().
Str("repository", "user").
Str("action", "checking_email_exists").
Str("email", email).
Msg("checking if email already exists")
var email_already_exists bool
// SQL EXISTS query
// EXISTS(...) returns true/false based on whether subquery returns rows
// More efficient than COUNT(*) or SELECT * for existence checks
query := `
SELECT EXISTS (
SELECT FROM users -- SELECT doesn't need columns for EXISTS
WHERE email = $1 -- Check for email match
AND deleted_at IS NULL -- Only check non-deleted users
)
`
// Execute query and scan boolean result
err := r.db.GetContext(
ctx,
&email_already_exists, // Boolean result
query,
email, // $1 - Email to check
)
if err != nil {
log.Error().
Str("repository", "user").
Str("action", "email_exists_check_error").
Str("email", email).
Err(err).
Msg("database error while checking email existence")
return true, err
}
if email_already_exists {
log.Info().
Str("repository", "user").
Str("action", "email_already_exists").
Str("email", email).
Bool("exists", true).
Msg("email already registered - registration will be blocked")
} else {
log.Debug().
Str("repository", "user").
Str("action", "email_available").
Str("email", email).
Bool("exists", false).
Msg("email is available for registration")
}
return email_already_exists, err
}
// UpdateLastLogin updates the user's last login timestamp and IP address.
// This is called after successful login for audit and security purposes.
//
// Why track last login?
// 1. Security: Detect unusual login patterns (new location, time)
// 2. User awareness: Show "Last login: 2 hours ago from New York"
// 3. Audit trail: Compliance requirements (who accessed when)
// 4. Account activity: Identify inactive accounts
// 5. Support: Help users verify their own activity
//
// What gets updated:
// - last_login_at: Current timestamp (when login occurred)
// - last_login_ip: IP address of login (for location/security analysis)
// - updated_at: Record last modification time
//
// IP address considerations:
// - Can be IPv4 or IPv6
// - Might be proxy/load balancer IP (need X-Forwarded-For)
// - Privacy concern: May need to anonymize after time period (GDPR)
// - Useful for: Geographic analysis, fraud detection
//
// Performance note:
// - This is a quick UPDATE (indexed by id)
// - Usually fast enough to include in login flow
// - Alternative: Update asynchronously if performance critical
//
// Error handling:
// - Returns error if update fails
// - Caller might ignore (login succeeds even if this fails)
// - Non-critical operation (login more important than tracking)
func (r *UserRepository) UpdateLastLogin(ctx context.Context, id uuid.UUID, ip *string) error {
var ipStr string
if ip != nil {
ipStr = *ip
}
log.Debug().
Str("repository", "user").
Str("action", "update_last_login_started").
Str("user_id", id.String()).
Str("ip", ipStr).
Msg("updating user last login timestamp and ip")
// SQL UPDATE query
// Uses NOW() for consistent database timestamp
query := `
UPDATE users
SET last_login_at = NOW(), -- Current database time
last_login_ip = $2, -- IP address from request
updated_at = NOW() -- Track this modification
WHERE id = $1 -- Only update this user
`
// Execute update
// ExecContext for queries that don't return rows (UPDATE, DELETE)
results, err := r.db.ExecContext(
ctx,
query,
id, // $1 - User ID
ipStr, // $2 - IP address
)
if err != nil {
log.Warn().
Str("repository", "user").
Str("action", "update_last_login_failed").
Str("user_id", id.String()).
Err(err).
Msg("failed to update last login timestamp")
return err
}
rowsAffected, _ := results.RowsAffected()
if rowsAffected == 0 {
log.Warn().
Str("repository", "user").
Str("action", "update_last_login_no_rows").
Str("user_id", id.String()).
Msg("update succeeded but no user was modified - user may not exist")
} else {
log.Debug().
Str("repository", "user").
Str("action", "update_last_login_success").
Str("user_id", id.String()).
Msg("last login updated successfully")
}
return nil
}
// UpdatePassword updates a user's password.
// This is used for:
// - Password change (user-initiated)
// - Password reset (forgot password flow)
// - Force password change (admin action)
//
// Security process:
// 1. Hash new password with bcrypt
// 2. Update password_hash in database
// 3. Update updated_at timestamp
//
// What happens after password change:
// - Caller should revoke all sessions (force re-login on all devices)
// - User receives email notification (security alert)
// - Audit log entry created
//
// Why hash before updating:
// - NEVER store plaintext passwords in database
// - bcrypt provides strong one-way hashing
// - Even database admins can't see actual passwords
// - Protects users even if database is breached
//
// Important considerations:
// 1. Validate new password strength before calling this
// 2. Verify user's identity (current password or reset token)
// 3. Rate limit password changes (prevent abuse)
// 4. Send notification to user's email
// 5. Consider revoking all sessions
//
// Error handling:
// - Returns error if hashing fails (rare, memory issues)
// - Returns error if update fails (user not found, database error)
func (r *UserRepository) UpdatePassword(ctx context.Context, id uuid.UUID, password string) error {
log.Info().
Str("repository", "user").
Str("action", "update_password_started").
Str("user_id", id.String()).
Msg("updating user password")
log.Debug().
Str("repository", "user").
Str("action", "hashing_new_password").
Int("bcrypt_cost", bcrypt.DefaultCost).
Msg("hashing new password with bcrypt")
// Step 1: Hash the new password
// Always use bcrypt for password hashing (never plaintext!)
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
log.Error().
Str("repository", "user").
Str("action", "password_hash_failed_on_update").
Str("user_id", id.String()).
Err(err).
Msg("failed to hash new password during update")
// Wrap error with context
return fmt.Errorf("failed to hash password: %w", err)
}
// SQL UPDATE query
query := `
UPDATE users
SET password_hash = $2, -- Update to new hashed password
updated_at = NOW() -- Track modification time
WHERE id = $1 -- Only update this user
`
// Execute update
results, err := r.db.ExecContext(
ctx,
query,
id, // $1 - User ID
string(hashedPassword), // $2 - New password hash
)
if err != nil {
log.Error().
Str("repository", "user").
Str("action", "update_password_failed").
Str("user_id", id.String()).
Err(err).
Msg("failed to update password in database")
return err
}
rowsAffected, _ := results.RowsAffected()
if rowsAffected == 0 {
log.Warn().
Str("repository", "user").
Str("action", "update_password_no_rows").
Str("user_id", id.String()).
Msg("password update succeeded but no user was modified")
} else {
log.Info().
Str("repository", "user").
Str("action", "update_password_success").
Str("user_id", id.String()).
Msg("password updated successfully - all sessions should be revoked")
}
return nil
}
// VerifyPassword checks if a provided password matches the user's stored password hash.
// This is the core of password-based authentication.
//
// How it works:
// 1. Extract password_hash from user (from database)
// 2. Use bcrypt.CompareHashAndPassword to verify
// 3. Return true if match, false if not
//
// bcrypt verification process:
// 1. Hash format: "$2a$10$salthashedpassword"
// 2. bcrypt extracts salt from stored hash
// 3. Hashes provided password with same salt
// 4. Compares result with stored hash
// 5. Returns nil error if match, error if mismatch
//
// Why bcrypt is good for this:
// - Timing-safe comparison (prevents timing attacks)
// - Salt is stored in hash (no separate storage needed)
// - Slow by design (prevents brute force)
// - Industry standard
//
// Security considerations:
// - Never log or display passwords
// - Don't reveal if email or password was wrong (prevents enumeration)
// - Rate limit login attempts (prevent brute force)
// - Consider account lockout after failed attempts
//
// Nil check importance:
// - If password_hash is nil, dereferencing causes panic
// - This might happen if:
// - User row is corrupted
// - Migration error
// - Direct database manipulation
//
// - Better to return false than crash
//
// Return values:
// - true: Password matches (authentication successful)
// - false: Password doesn't match OR hash is nil (authentication failed)
func (r *UserRepository) VerifyPassword(user *models.User, providedPassword string) bool {
log.Debug().
Str("repository", "user").
Str("action", "verify_password_started").
Str("user_id", user.ID.String()).
Str("email", user.Email).
Bool("has_password_hash", user.PasswordHash != nil).
Msg("verifying user password")
// Safety check: Prevent panic if password_hash is nil
// This shouldn't happen in normal operation, but better safe than crashed
if user.PasswordHash == nil {
log.Error().
Str("repository", "user").
Str("action", "missing_password_hash").
Str("user_id", user.ID.String()).
Str("email", user.Email).
Msg("CRITICAL: user has no password hash - data corruption or migration issue")
return false // No hash = can't verify = authentication fails
}
// Use bcrypt to compare provided password with stored hash
// CompareHashAndPassword:
// - Takes stored hash as []byte
// - Takes provided password as []byte
// - Returns nil if match, error if mismatch
// - Handles salt extraction and timing-safe comparison
err := bcrypt.CompareHashAndPassword([]byte(*user.PasswordHash), []byte(providedPassword))
if err != nil {
log.Warn().
Str("repository", "user").
Str("action", "password_verification_failed").
Str("user_id", user.ID.String()).
Str("email", user.Email).
Err(err).
Msg("password verification failed - incorrect password")
return false
}
log.Info().
Str("repository", "user").
Str("action", "password_verification_success").
Str("user_id", user.ID.String()).
Str("email", user.Email).
Str("tenant_id", user.TenantID.String()).
Str("role", user.Role).
Msg("password verified successfully")
return true
}