351 lines
7.1 KiB
Go
351 lines
7.1 KiB
Go
// AngelaMos | 2026
|
|
// service.go
|
|
|
|
package user
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/auth"
|
|
"github.com/carterperez-dev/monitor-the-situation/backend/internal/core"
|
|
)
|
|
|
|
type Service struct {
|
|
repo Repository
|
|
}
|
|
|
|
func NewService(repo Repository) *Service {
|
|
return &Service{repo: repo}
|
|
}
|
|
|
|
func (s *Service) GetByID(
|
|
ctx context.Context,
|
|
id string,
|
|
) (*auth.UserInfo, error) {
|
|
user, err := s.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return toUserInfo(user), nil
|
|
}
|
|
|
|
func (s *Service) GetByEmail(
|
|
ctx context.Context,
|
|
email string,
|
|
) (*auth.UserInfo, error) {
|
|
user, err := s.repo.GetByEmail(ctx, strings.ToLower(email))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return toUserInfo(user), nil
|
|
}
|
|
|
|
func (s *Service) Create(
|
|
ctx context.Context,
|
|
email, passwordHash, name string,
|
|
) (*auth.UserInfo, error) {
|
|
user := &User{
|
|
ID: uuid.New().String(),
|
|
Email: strings.ToLower(email),
|
|
PasswordHash: passwordHash,
|
|
Name: name,
|
|
Role: RoleUser,
|
|
Tier: TierFree,
|
|
}
|
|
|
|
if err := s.repo.Create(ctx, user); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return toUserInfo(user), nil
|
|
}
|
|
|
|
func (s *Service) IncrementTokenVersion(
|
|
ctx context.Context,
|
|
userID string,
|
|
) error {
|
|
return s.repo.IncrementTokenVersion(ctx, userID)
|
|
}
|
|
|
|
func (s *Service) UpdatePassword(
|
|
ctx context.Context,
|
|
userID, passwordHash string,
|
|
) error {
|
|
return s.repo.UpdatePassword(ctx, userID, passwordHash)
|
|
}
|
|
|
|
// SetRole flips a user's role. Used by ADMIN_EMAIL bootstrapping in
|
|
// auth.Service: the configured email gets promoted to admin on every
|
|
// login/register so a fresh DB after env changes still has an admin.
|
|
func (s *Service) SetRole(ctx context.Context, userID, role string) error {
|
|
if role != RoleUser && role != RoleAdmin {
|
|
return fmt.Errorf(
|
|
"set role: invalid role %q: %w",
|
|
role,
|
|
core.ErrInvalidInput,
|
|
)
|
|
}
|
|
user, err := s.repo.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
user.Role = role
|
|
return s.repo.Update(ctx, user)
|
|
}
|
|
|
|
// UpdateEmail changes a user's email after re-verifying their password.
|
|
// Email is normalized to lowercase. Returns ErrDuplicateKey if the new
|
|
// email is already taken by another account.
|
|
func (s *Service) UpdateEmail(
|
|
ctx context.Context,
|
|
userID, currentPassword, newEmail string,
|
|
) (*User, error) {
|
|
newEmail = strings.ToLower(strings.TrimSpace(newEmail))
|
|
if newEmail == "" {
|
|
return nil, fmt.Errorf("update email: empty: %w", core.ErrInvalidInput)
|
|
}
|
|
|
|
user, err := s.repo.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
valid, _, err := core.VerifyPasswordWithRehash(
|
|
currentPassword,
|
|
user.PasswordHash,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("verify password: %w", err)
|
|
}
|
|
if !valid {
|
|
return nil, fmt.Errorf("update email: %w", core.ErrUnauthorized)
|
|
}
|
|
|
|
if user.Email == newEmail {
|
|
return user, nil
|
|
}
|
|
|
|
if err := s.repo.UpdateEmail(ctx, userID, newEmail); err != nil {
|
|
return nil, err
|
|
}
|
|
user.Email = newEmail
|
|
return user, nil
|
|
}
|
|
|
|
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
|
|
return s.repo.GetByID(ctx, id)
|
|
}
|
|
|
|
func (s *Service) UpdateUser(
|
|
ctx context.Context,
|
|
id string,
|
|
req UpdateUserRequest,
|
|
) (*User, error) {
|
|
user, err := s.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if req.Name != nil {
|
|
user.Name = *req.Name
|
|
}
|
|
|
|
if err := s.repo.Update(ctx, user); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (s *Service) AdminUpdateUser(
|
|
ctx context.Context,
|
|
id string,
|
|
req AdminUpdateUserRequest,
|
|
) (*User, error) {
|
|
user, err := s.repo.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if req.Name != nil {
|
|
user.Name = *req.Name
|
|
}
|
|
if req.Role != nil {
|
|
if *req.Role != RoleUser && *req.Role != RoleAdmin {
|
|
return nil, fmt.Errorf(
|
|
"update role: invalid role %q: %w",
|
|
*req.Role,
|
|
core.ErrInvalidInput,
|
|
)
|
|
}
|
|
user.Role = *req.Role
|
|
}
|
|
if req.Tier != nil {
|
|
if *req.Tier != TierFree && *req.Tier != TierPro &&
|
|
*req.Tier != TierEnterprise {
|
|
return nil, fmt.Errorf(
|
|
"update tier: invalid tier %q: %w",
|
|
*req.Tier,
|
|
core.ErrInvalidInput,
|
|
)
|
|
}
|
|
user.Tier = *req.Tier
|
|
}
|
|
|
|
if err := s.repo.Update(ctx, user); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (s *Service) AdminCreateUser(
|
|
ctx context.Context,
|
|
req AdminCreateUserRequest,
|
|
) (*User, error) {
|
|
hash, err := core.HashPassword(req.Password)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hash password: %w", err)
|
|
}
|
|
|
|
user := &User{
|
|
ID: uuid.New().String(),
|
|
Email: strings.ToLower(req.Email),
|
|
PasswordHash: hash,
|
|
Name: req.Name,
|
|
Role: RoleUser,
|
|
Tier: TierFree,
|
|
}
|
|
if req.Role != nil {
|
|
if *req.Role != RoleUser && *req.Role != RoleAdmin {
|
|
return nil, fmt.Errorf(
|
|
"create user: invalid role %q: %w",
|
|
*req.Role,
|
|
core.ErrInvalidInput,
|
|
)
|
|
}
|
|
user.Role = *req.Role
|
|
}
|
|
if req.Tier != nil {
|
|
if *req.Tier != TierFree && *req.Tier != TierPro &&
|
|
*req.Tier != TierEnterprise {
|
|
return nil, fmt.Errorf(
|
|
"create user: invalid tier %q: %w",
|
|
*req.Tier,
|
|
core.ErrInvalidInput,
|
|
)
|
|
}
|
|
user.Tier = *req.Tier
|
|
}
|
|
|
|
if err := s.repo.Create(ctx, user); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (s *Service) DeleteUser(ctx context.Context, id string) error {
|
|
return s.repo.SoftDelete(ctx, id)
|
|
}
|
|
|
|
func (s *Service) ListUsers(
|
|
ctx context.Context,
|
|
params ListUsersParams,
|
|
) ([]User, int, error) {
|
|
return s.repo.List(ctx, params)
|
|
}
|
|
|
|
func (s *Service) GetMe(ctx context.Context, userID string) (*User, error) {
|
|
if userID == "" {
|
|
return nil, fmt.Errorf("get me: %w", core.ErrUnauthorized)
|
|
}
|
|
|
|
user, err := s.repo.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return user, nil
|
|
}
|
|
|
|
func (s *Service) UpdateMe(
|
|
ctx context.Context,
|
|
userID string,
|
|
req UpdateUserRequest,
|
|
) (*User, error) {
|
|
if userID == "" {
|
|
return nil, fmt.Errorf("update me: %w", core.ErrUnauthorized)
|
|
}
|
|
|
|
return s.UpdateUser(ctx, userID, req)
|
|
}
|
|
|
|
func (s *Service) DeleteMe(ctx context.Context, userID string) error {
|
|
if userID == "" {
|
|
return fmt.Errorf("delete me: %w", core.ErrUnauthorized)
|
|
}
|
|
|
|
return s.repo.SoftDelete(ctx, userID)
|
|
}
|
|
|
|
func (s *Service) EmailExists(
|
|
ctx context.Context,
|
|
email string,
|
|
) (bool, error) {
|
|
exists, err := s.repo.ExistsByEmail(ctx, email)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
func (s *Service) CanDeleteUser(
|
|
ctx context.Context,
|
|
requesterID, targetID string,
|
|
) error {
|
|
if requesterID == targetID {
|
|
return nil
|
|
}
|
|
|
|
requester, err := s.repo.GetByID(ctx, requesterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !requester.IsAdmin() {
|
|
return fmt.Errorf("delete user: %w", core.ErrForbidden)
|
|
}
|
|
|
|
target, err := s.repo.GetByID(ctx, targetID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if target.IsAdmin() {
|
|
return fmt.Errorf("cannot delete admin users: %w", core.ErrForbidden)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func toUserInfo(u *User) *auth.UserInfo {
|
|
return &auth.UserInfo{
|
|
ID: u.ID,
|
|
Email: u.Email,
|
|
Name: u.Name,
|
|
PasswordHash: u.PasswordHash,
|
|
Role: u.Role,
|
|
Tier: u.Tier,
|
|
TokenVersion: u.TokenVersion,
|
|
}
|
|
}
|
|
|
|
var _ auth.UserProvider = (*Service)(nil)
|