internal/repository/user_postgres.go
Rama main · réplica de lectura del repositorio original.
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,
}
}