feat(backend): implement user, ACL, and audit stacks
Add UserService (GetMe, UpdateMe, admin CRUD with block/unblock), UserHandler (/users, /users/me), ACLHandler (GET/PUT /acl/:type/:id), AuditHandler (GET /audit with all filters). Fix UserRepo.Update to include is_blocked. Wire all remaining routes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"tanabata/backend/internal/domain"
|
||||
"tanabata/backend/internal/port"
|
||||
)
|
||||
|
||||
// UserService handles user CRUD and profile management.
|
||||
type UserService struct {
|
||||
users port.UserRepo
|
||||
audit *AuditService
|
||||
}
|
||||
|
||||
// NewUserService creates a UserService.
|
||||
func NewUserService(users port.UserRepo, audit *AuditService) *UserService {
|
||||
return &UserService{users: users, audit: audit}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Self-service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetMe returns the profile of the currently authenticated user.
|
||||
func (s *UserService) GetMe(ctx context.Context) (*domain.User, error) {
|
||||
userID, _, _ := domain.UserFromContext(ctx)
|
||||
return s.users.GetByID(ctx, userID)
|
||||
}
|
||||
|
||||
// UpdateMeParams holds fields a user may change on their own profile.
|
||||
type UpdateMeParams struct {
|
||||
Name string // empty = no change
|
||||
Password *string // nil = no change
|
||||
}
|
||||
|
||||
// UpdateMe allows a user to change their own name and/or password.
|
||||
func (s *UserService) UpdateMe(ctx context.Context, p UpdateMeParams) (*domain.User, error) {
|
||||
userID, _, _ := domain.UserFromContext(ctx)
|
||||
|
||||
current, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
patch := *current
|
||||
if p.Name != "" {
|
||||
patch.Name = p.Name
|
||||
}
|
||||
if p.Password != nil {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(*p.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("UserService.UpdateMe hash: %w", err)
|
||||
}
|
||||
patch.Password = string(hash)
|
||||
}
|
||||
|
||||
return s.users.Update(ctx, userID, &patch)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// List returns a paginated list of users (admin only — caller must enforce).
|
||||
func (s *UserService) List(ctx context.Context, params port.OffsetParams) (*domain.UserPage, error) {
|
||||
return s.users.List(ctx, params)
|
||||
}
|
||||
|
||||
// Get returns a user by ID (admin only).
|
||||
func (s *UserService) Get(ctx context.Context, id int16) (*domain.User, error) {
|
||||
return s.users.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// CreateParams holds fields for creating a new user.
|
||||
type CreateUserParams struct {
|
||||
Name string
|
||||
Password string
|
||||
IsAdmin bool
|
||||
CanCreate bool
|
||||
}
|
||||
|
||||
// Create inserts a new user with a bcrypt-hashed password (admin only).
|
||||
func (s *UserService) Create(ctx context.Context, p CreateUserParams) (*domain.User, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(p.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("UserService.Create hash: %w", err)
|
||||
}
|
||||
|
||||
u := &domain.User{
|
||||
Name: p.Name,
|
||||
Password: string(hash),
|
||||
IsAdmin: p.IsAdmin,
|
||||
CanCreate: p.CanCreate,
|
||||
}
|
||||
created, err := s.users.Create(ctx, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_ = s.audit.Log(ctx, "user_create", nil, nil, map[string]any{"target_user_id": created.ID})
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// UpdateAdminParams holds fields an admin may change on any user.
|
||||
type UpdateAdminParams struct {
|
||||
IsAdmin *bool
|
||||
CanCreate *bool
|
||||
IsBlocked *bool
|
||||
}
|
||||
|
||||
// UpdateAdmin applies an admin-level patch to a user.
|
||||
func (s *UserService) UpdateAdmin(ctx context.Context, id int16, p UpdateAdminParams) (*domain.User, error) {
|
||||
current, err := s.users.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
patch := *current
|
||||
if p.IsAdmin != nil {
|
||||
patch.IsAdmin = *p.IsAdmin
|
||||
}
|
||||
if p.CanCreate != nil {
|
||||
patch.CanCreate = *p.CanCreate
|
||||
}
|
||||
if p.IsBlocked != nil {
|
||||
patch.IsBlocked = *p.IsBlocked
|
||||
}
|
||||
|
||||
updated, err := s.users.Update(ctx, id, &patch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log block/unblock specifically.
|
||||
if p.IsBlocked != nil {
|
||||
action := "user_unblock"
|
||||
if *p.IsBlocked {
|
||||
action = "user_block"
|
||||
}
|
||||
_ = s.audit.Log(ctx, action, nil, nil, map[string]any{"target_user_id": id})
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// Delete removes a user by ID (admin only).
|
||||
func (s *UserService) Delete(ctx context.Context, id int16) error {
|
||||
if err := s.users.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.audit.Log(ctx, "user_delete", nil, nil, map[string]any{"target_user_id": id})
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user