package accounts import ( "context" "errors" "fmt" "log/slog" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "golang.org/x/crypto/bcrypt" "github.com/memohai/memoh/internal/db" "github.com/memohai/memoh/internal/db/sqlc" ) // Service provides account (credential) management for users. type Service struct { queries *sqlc.Queries logger *slog.Logger } var ( ErrInvalidPassword = errors.New("invalid password") ErrInvalidCredentials = errors.New("invalid credentials") ErrInactiveAccount = errors.New("account is inactive") ) // NewService creates a new accounts service. func NewService(log *slog.Logger, queries *sqlc.Queries) *Service { if log == nil { log = slog.Default() } return &Service{ queries: queries, logger: log.With(slog.String("service", "accounts")), } } // Get returns an account by user id. func (s *Service) Get(ctx context.Context, userID string) (Account, error) { if s.queries == nil { return Account{}, fmt.Errorf("account queries not configured") } pgID, err := db.ParseUUID(userID) if err != nil { return Account{}, err } row, err := s.queries.GetAccountByUserID(ctx, pgID) if err != nil { return Account{}, err } return toAccount(row), nil } // Login authenticates by identity (username or email) and password. func (s *Service) Login(ctx context.Context, identity, password string) (Account, error) { if s.queries == nil { return Account{}, fmt.Errorf("account queries not configured") } identity = strings.TrimSpace(identity) if identity == "" || strings.TrimSpace(password) == "" { return Account{}, ErrInvalidCredentials } row, err := s.queries.GetAccountByIdentity(ctx, pgtype.Text{String: identity, Valid: true}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return Account{}, ErrInvalidCredentials } return Account{}, err } if !row.IsActive { return Account{}, ErrInactiveAccount } if !row.PasswordHash.Valid { return Account{}, ErrInvalidCredentials } if err := bcrypt.CompareHashAndPassword([]byte(row.PasswordHash.String), []byte(password)); err != nil { return Account{}, ErrInvalidCredentials } if _, err := s.queries.UpdateAccountLastLogin(ctx, row.ID); err != nil { if s.logger != nil { s.logger.Warn("touch last login failed", slog.Any("error", err)) } } return toAccount(row), nil } // ListAccounts returns all accounts. func (s *Service) ListAccounts(ctx context.Context) ([]Account, error) { if s.queries == nil { return nil, fmt.Errorf("account queries not configured") } rows, err := s.queries.ListAccounts(ctx) if err != nil { return nil, err } items := make([]Account, 0, len(rows)) for _, row := range rows { items = append(items, toAccount(row)) } return items, nil } // IsAdmin checks if the user has admin role. func (s *Service) IsAdmin(ctx context.Context, userID string) (bool, error) { if s.queries == nil { return false, fmt.Errorf("account queries not configured") } pgID, err := db.ParseUUID(userID) if err != nil { return false, err } row, err := s.queries.GetAccountByUserID(ctx, pgID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return false, nil } return false, err } return isAdminRole(row.Role), nil } // Create creates a new account for an existing user. func (s *Service) Create(ctx context.Context, userID string, req CreateAccountRequest) (Account, error) { if s.queries == nil { return Account{}, fmt.Errorf("account queries not configured") } username := strings.TrimSpace(req.Username) if username == "" { return Account{}, fmt.Errorf("username is required") } password := strings.TrimSpace(req.Password) if password == "" { return Account{}, fmt.Errorf("password is required") } role, err := normalizeRole(req.Role) if err != nil { return Account{}, err } hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return Account{}, err } displayName := strings.TrimSpace(req.DisplayName) if displayName == "" { displayName = username } avatarURL := strings.TrimSpace(req.AvatarURL) email := strings.TrimSpace(req.Email) isActive := true if req.IsActive != nil { isActive = *req.IsActive } pgUserID, err := db.ParseUUID(userID) if err != nil { return Account{}, err } emailValue := pgtype.Text{Valid: false} if email != "" { emailValue = pgtype.Text{String: email, Valid: true} } displayValue := pgtype.Text{String: displayName, Valid: displayName != ""} avatarValue := pgtype.Text{Valid: false} if avatarURL != "" { avatarValue = pgtype.Text{String: avatarURL, Valid: true} } row, err := s.queries.CreateAccount(ctx, sqlc.CreateAccountParams{ UserID: pgUserID, Username: pgtype.Text{String: username, Valid: true}, Email: emailValue, PasswordHash: pgtype.Text{String: string(hashed), Valid: true}, Role: role, DisplayName: displayValue, AvatarUrl: avatarValue, IsActive: isActive, DataRoot: pgtype.Text{Valid: false}, }) if err != nil { return Account{}, err } return toAccount(row), nil } // CreateHuman keeps compatibility with older call sites. // // Deprecated: use Create directly. func (s *Service) CreateHuman(ctx context.Context, userID string, req CreateAccountRequest) (Account, error) { userID = strings.TrimSpace(userID) if userID == "" { if s.queries == nil { return Account{}, fmt.Errorf("account queries not configured") } userRow, err := s.queries.CreateUser(ctx, sqlc.CreateUserParams{ IsActive: true, Metadata: []byte("{}"), }) if err != nil { return Account{}, err } if !userRow.ID.Valid { return Account{}, fmt.Errorf("create user: invalid id") } userID = userRow.ID.String() } return s.Create(ctx, userID, req) } // UpdateAdmin updates account fields as admin. func (s *Service) UpdateAdmin(ctx context.Context, userID string, req UpdateAccountRequest) (Account, error) { if s.queries == nil { return Account{}, fmt.Errorf("account queries not configured") } pgID, err := db.ParseUUID(userID) if err != nil { return Account{}, err } existing, err := s.queries.GetAccountByUserID(ctx, pgID) if err != nil { return Account{}, err } role := existing.Role if req.Role != nil { role, err = normalizeRole(*req.Role) if err != nil { return Account{}, err } } displayName := strings.TrimSpace(existing.DisplayName.String) if req.DisplayName != nil { displayName = strings.TrimSpace(*req.DisplayName) } if displayName == "" { displayName = strings.TrimSpace(existing.Username.String) } avatarURL := strings.TrimSpace(existing.AvatarUrl.String) if req.AvatarURL != nil { avatarURL = strings.TrimSpace(*req.AvatarURL) } isActive := existing.IsActive if req.IsActive != nil { isActive = *req.IsActive } row, err := s.queries.UpdateAccountAdmin(ctx, sqlc.UpdateAccountAdminParams{ UserID: pgID, Role: role, DisplayName: pgtype.Text{String: displayName, Valid: displayName != ""}, AvatarUrl: pgtype.Text{String: avatarURL, Valid: avatarURL != ""}, IsActive: isActive, }) if err != nil { return Account{}, err } return toAccount(row), nil } // UpdateProfile updates the user's profile. func (s *Service) UpdateProfile(ctx context.Context, userID string, req UpdateProfileRequest) (Account, error) { if s.queries == nil { return Account{}, fmt.Errorf("account queries not configured") } pgID, err := db.ParseUUID(userID) if err != nil { return Account{}, err } existing, err := s.queries.GetAccountByUserID(ctx, pgID) if err != nil { return Account{}, err } displayName := strings.TrimSpace(existing.DisplayName.String) if req.DisplayName != nil { displayName = strings.TrimSpace(*req.DisplayName) } if displayName == "" { displayName = strings.TrimSpace(existing.Username.String) } avatarURL := strings.TrimSpace(existing.AvatarUrl.String) if req.AvatarURL != nil { avatarURL = strings.TrimSpace(*req.AvatarURL) } row, err := s.queries.UpdateAccountProfile(ctx, sqlc.UpdateAccountProfileParams{ ID: pgID, DisplayName: pgtype.Text{String: displayName, Valid: displayName != ""}, AvatarUrl: pgtype.Text{String: avatarURL, Valid: avatarURL != ""}, IsActive: existing.IsActive, }) if err != nil { return Account{}, err } return toAccount(row), nil } // UpdatePassword changes the password after verifying the current one. func (s *Service) UpdatePassword(ctx context.Context, userID, currentPassword, newPassword string) error { if s.queries == nil { return fmt.Errorf("account queries not configured") } if strings.TrimSpace(newPassword) == "" { return fmt.Errorf("new password is required") } pgID, err := db.ParseUUID(userID) if err != nil { return err } existing, err := s.queries.GetAccountByUserID(ctx, pgID) if err != nil { return err } if strings.TrimSpace(currentPassword) == "" { return ErrInvalidPassword } if !existing.PasswordHash.Valid { return ErrInvalidPassword } if err := bcrypt.CompareHashAndPassword([]byte(existing.PasswordHash.String), []byte(currentPassword)); err != nil { return ErrInvalidPassword } hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return err } _, err = s.queries.UpdateAccountPassword(ctx, sqlc.UpdateAccountPasswordParams{ ID: pgID, PasswordHash: pgtype.Text{String: string(hashed), Valid: true}, }) return err } // ResetPassword sets a new password without requiring the current one. func (s *Service) ResetPassword(ctx context.Context, userID, newPassword string) error { if s.queries == nil { return fmt.Errorf("account queries not configured") } if strings.TrimSpace(newPassword) == "" { return fmt.Errorf("new password is required") } pgID, err := db.ParseUUID(userID) if err != nil { return err } hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return err } _, err = s.queries.UpdateAccountPassword(ctx, sqlc.UpdateAccountPasswordParams{ ID: pgID, PasswordHash: pgtype.Text{String: string(hashed), Valid: true}, }) return err } func normalizeRole(raw string) (string, error) { role := strings.ToLower(strings.TrimSpace(raw)) if role == "" { return "member", nil } if role != "member" && role != "admin" { return "", fmt.Errorf("invalid role: %s", raw) } return role, nil } func isAdminRole(role any) bool { if role == nil { return false } switch v := role.(type) { case string: return strings.EqualFold(v, "admin") case fmt.Stringer: return strings.EqualFold(v.String(), "admin") default: return strings.EqualFold(fmt.Sprint(v), "admin") } } func toAccount(row sqlc.User) Account { username := strings.TrimSpace(row.Username.String) email := "" if row.Email.Valid { email = row.Email.String } displayName := "" if row.DisplayName.Valid { displayName = row.DisplayName.String } if displayName == "" { displayName = username } avatarURL := "" if row.AvatarUrl.Valid { avatarURL = row.AvatarUrl.String } createdAt := time.Time{} if row.CreatedAt.Valid { createdAt = row.CreatedAt.Time } updatedAt := time.Time{} if row.UpdatedAt.Valid { updatedAt = row.UpdatedAt.Time } lastLogin := time.Time{} if row.LastLoginAt.Valid { lastLogin = row.LastLoginAt.Time } return Account{ ID: row.ID.String(), Username: username, Email: email, Role: row.Role, DisplayName: displayName, AvatarURL: avatarURL, IsActive: row.IsActive, CreatedAt: createdAt, UpdatedAt: updatedAt, LastLoginAt: lastLogin, } }