Ctrl K
ComunidadGuías…

internal/repository/user_postgres.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package repository

import (
	"context"
	"fmt"
	"strings"
	"time"
	"xprem/internal/database"
	"xprem/internal/database/postgres/pgdb"
)

// User is a dashboard user account. PasswordHash is only populated by the
// lookups the auth flows need (by email, by id), never by listings, and must
// never leave the service layer.
type User struct {
	Id           string
	Email        string
	PasswordHash string
	IsAdmin      bool
	// Enabled gates every sign-in path. Accounts are enabled on creation; an
	// admin can revoke access without deleting the account, and SSO manual
	// validation provisions new accounts disabled until an admin approves them.
	Enabled   bool
	CreatedAt time.Time
	// Nil until the account's first successful sign-in.
	LastConnectedAt *time.Time
	// SessionVersion is the account's security generation; every dashboard JWT
	// carries the value it was minted with, and a mismatch means the session was
	// revoked. Only the lookups that authenticate populate it; GetUsers does not.
	SessionVersion int32
}

type InsertUserParameters struct {
	ID           string
	Email        string
	PasswordHash string
	IsAdmin      bool
	Enabled      bool
}

// NormalizeEmail is the single place an email is canonicalized before hitting
// the users table. Rows are always stored in this form, which is what lets the
// plain UNIQUE(email) constraint enforce case-insensitive uniqueness.
func NormalizeEmail(email string) string {
	return strings.ToLower(strings.TrimSpace(email))
}

type PostgresUserRepository struct {
	engine *database.Engine
}

func NewPostgresUserRepository(engine *database.Engine) *PostgresUserRepository {
	return &PostgresUserRepository{
		engine: engine,
	}
}

func (s *PostgresUserRepository) InsertUser(ctx context.Context, params InsertUserParameters) (User, error) {
	email := NormalizeEmail(params.Email)
	row, err := s.engine.Queries.InsertUser(ctx, pgdb.InsertUserParams{
		ID:           ToPgUUID(params.ID),
		Email:        email,
		PasswordHash: params.PasswordHash,
		IsAdmin:      params.IsAdmin,
		Enabled:      params.Enabled,
	})
	if err != nil {
		if database.IsUniqueViolation(err) {
			return User{}, &ErrResourceAlreadyExists{Resource: "user", Identifier: email}
		}
		return User{}, fmt.Errorf("failed to insert user into database: %w", err)
	}
	return User{
		Id:        row.ID.String(),
		Email:     row.Email,
		IsAdmin:   row.IsAdmin,
		Enabled:   row.Enabled,
		CreatedAt: row.CreatedAt.Time,
	}, nil
}

func (s *PostgresUserRepository) GetUserByEmail(ctx context.Context, email string) (User, error) {
	normalizedEmail := NormalizeEmail(email)
	row, err := s.engine.Queries.GetUserByEmail(ctx, normalizedEmail)
	if err != nil {
		if database.IsNoRows(err) {
			return User{}, &ErrResourceNotFound{Resource: "user", Identifier: normalizedEmail}
		}
		return User{}, fmt.Errorf("failed to retrieve user from database: %w", err)
	}
	return userFromRow(row), nil
}

func (s *PostgresUserRepository) GetUserByID(ctx context.Context, id string) (User, error) {
	row, err := s.engine.Queries.GetUserByID(ctx, ToPgUUID(id))
	if err != nil {
		if database.IsNoRows(err) {
			return User{}, &ErrResourceNotFound{Resource: "user", Identifier: id}
		}
		return User{}, fmt.Errorf("failed to retrieve user from database: %w", err)
	}
	return userFromRow(row), nil
}

func (s *PostgresUserRepository) GetUsers(ctx context.Context) ([]User, error) {
	rows, err := s.engine.Queries.GetUsers(ctx)
	if err != nil {
		return nil, fmt.Errorf("failed to retrieve users from database: %w", err)
	}
	users := make([]User, len(rows))
	for i, row := range rows {
		users[i] = User{
			Id:              row.ID.String(),
			Email:           row.Email,
			IsAdmin:         row.IsAdmin,
			Enabled:         row.Enabled,
			CreatedAt:       row.CreatedAt.Time,
			LastConnectedAt: FromPgTimestamptz(row.LastConnectedAt),
		}
	}
	return users, nil
}

func (s *PostgresUserRepository) DeleteUserByID(ctx context.Context, id string) error {
	commandTag, err := s.engine.Queries.DeleteUserByID(ctx, ToPgUUID(id))
	if err != nil {
		return fmt.Errorf("failed to delete user from database: %w", err)
	}
	if commandTag.RowsAffected() == 0 {
		// The guarded query matches no row both for a missing user and for the
		// last remaining admin, so look the row up to tell the two apart.
		if _, lookupErr := s.GetUserByID(ctx, id); lookupErr != nil {
			return lookupErr
		}
		return ErrWouldLeaveNoAdmin
	}
	return nil
}

func (s *PostgresUserRepository) UpdateUserPassword(ctx context.Context, id string, passwordHash string) error {
	commandTag, err := s.engine.Queries.UpdateUserPasswordByID(ctx, pgdb.UpdateUserPasswordByIDParams{
		ID:           ToPgUUID(id),
		PasswordHash: passwordHash,
	})
	if err != nil {
		return fmt.Errorf("failed to update user password in database: %w", err)
	}
	if commandTag.RowsAffected() == 0 {
		return &ErrResourceNotFound{Resource: "user", Identifier: id}
	}
	return nil
}

func (s *PostgresUserRepository) UpdateUserIsAdmin(ctx context.Context, id string, isAdmin bool) error {
	commandTag, err := s.engine.Queries.UpdateUserIsAdminByID(ctx, pgdb.UpdateUserIsAdminByIDParams{
		ID:      ToPgUUID(id),
		IsAdmin: isAdmin,
	})
	if err != nil {
		return fmt.Errorf("failed to update user admin flag in database: %w", err)
	}
	if commandTag.RowsAffected() == 0 {
		// The guarded query matches no row both for a missing user and for the
		// last remaining admin, so look the row up to tell the two apart.
		if _, lookupErr := s.GetUserByID(ctx, id); lookupErr != nil {
			return lookupErr
		}
		return ErrWouldLeaveNoAdmin
	}
	return nil
}

func (s *PostgresUserRepository) UpdateUserEnabled(ctx context.Context, id string, enabled bool) error {
	commandTag, err := s.engine.Queries.UpdateUserEnabledByID(ctx, pgdb.UpdateUserEnabledByIDParams{
		ID:      ToPgUUID(id),
		Enabled: enabled,
	})
	if err != nil {
		return fmt.Errorf("failed to update user enabled flag in database: %w", err)
	}
	if commandTag.RowsAffected() == 0 {
		// The guarded query matches no row both for a missing user and for the
		// last remaining enabled admin, so look the row up to tell the two apart.
		if _, lookupErr := s.GetUserByID(ctx, id); lookupErr != nil {
			return lookupErr
		}
		return ErrWouldLeaveNoAdmin
	}
	return nil
}

// BumpUserSessionVersion revokes every session the account currently holds:
// each of its live JWTs carries the previous generation and stops validating
// on its next use, access tokens included.
func (s *PostgresUserRepository) BumpUserSessionVersion(ctx context.Context, id string) error {
	commandTag, err := s.engine.Queries.BumpUserSessionVersion(ctx, ToPgUUID(id))
	if err != nil {
		return fmt.Errorf("failed to bump user session version in database: %w", err)
	}
	if commandTag.RowsAffected() == 0 {
		return &ErrResourceNotFound{Resource: "user", Identifier: id}
	}
	return nil
}

func (s *PostgresUserRepository) TouchUserLastConnected(ctx context.Context, id string) error {
	if err := s.engine.Queries.TouchUserLastConnectedAt(ctx, ToPgUUID(id)); err != nil {
		return fmt.Errorf("failed to touch user last connection in database: %w", err)
	}
	return nil
}

func userFromRow(row pgdb.User) User {
	return User{
		Id:              row.ID.String(),
		Email:           row.Email,
		PasswordHash:    row.PasswordHash,
		IsAdmin:         row.IsAdmin,
		Enabled:         row.Enabled,
		CreatedAt:       row.CreatedAt.Time,
		LastConnectedAt: FromPgTimestamptz(row.LastConnectedAt),
		SessionVersion:  row.SessionVersion,
	}
}