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

493 lines
16 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"
"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 {
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) {
// 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)
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
// Wrap error with context for better debugging
return nil, fmt.Errorf("failed to hash password : %w", err)
}
// 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,
)
return user, err
}
// 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) {
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 {
return nil, nil // User not found (not an error)
}
if err != nil {
return nil, err // Real database error
}
return user, err
}
// 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) {
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 {
return nil, nil // User not found (not an error)
}
if err != nil {
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) {
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
)
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 {
// 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)
_, err := r.db.ExecContext(
ctx,
query,
id, // $1 - User ID
ip, // $2 - IP address (pointer allows NULL)
)
return err
}
// 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 {
// 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 {
// 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
_, err = r.db.ExecContext(
ctx,
query,
id, // $1 - User ID
string(hashedPassword), // $2 - New password hash
)
return err
}
// 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 {
// 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 {
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))
// Return true if err is nil (passwords match)
// Return false if err is not nil (passwords don't match)
return err == nil
}