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 }