persona-community-2/services/persona-api/internal/adapter/memory/user.go
2026-02-23 10:54:06 +00:00

244 lines
6.2 KiB
Go

package memory
import (
"context"
"sync"
"time"
"git.threesix.ai/jordan/persona-community-2/pkg/auth"
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/domain"
"git.threesix.ai/jordan/persona-community-2/services/persona-api/internal/port"
)
// Compile-time interface check.
var _ port.UserRepository = (*UserRepository)(nil)
// UserRepository is an in-memory user store with bcrypt password hashing.
// Pre-populated with demo users for standalone development.
type UserRepository struct {
mu sync.RWMutex
users map[domain.UserID]*domain.User
passwords map[domain.UserID]string // bcrypt hashes
roles map[domain.UserID][]string // role lists
byEmail map[string]domain.UserID // email → user ID index
}
// NewUserRepository creates a new in-memory user repository seeded with demo users.
// If devEmail is non-empty, an additional user is seeded with that email and devPassword
// so the developer's account survives server restarts without re-registering.
func NewUserRepository(devEmail, devPassword string) *UserRepository {
repo := &UserRepository{
users: make(map[domain.UserID]*domain.User),
passwords: make(map[domain.UserID]string),
roles: make(map[domain.UserID][]string),
byEmail: make(map[string]domain.UserID),
}
// Seed demo users with bcrypt-hashed passwords.
// Passwords meet complexity requirements (min 8 chars, uppercase, lowercase, digit).
repo.seedUser("usr_test_001", "test@example.com", "Test User", "Password123", []string{"user"})
repo.seedUser("usr_admin_001", "admin@example.com", "Admin User", "Admin1234", []string{"admin", "user"})
// Seed the developer's own account if DEV_USER_EMAIL is configured.
// This ensures the email is always registered after restarts without manual re-registration.
if devEmail != "" {
repo.seedUser("usr_dev_001", devEmail, "Dev User", devPassword, []string{"admin", "user"})
}
return repo
}
func (r *UserRepository) seedUser(id, email, name, password string, userRoles []string) {
uid := domain.UserID(id)
now := time.Now()
hash, err := auth.HashPassword(password)
if err != nil {
panic("failed to hash seed password: " + err.Error())
}
r.users[uid] = &domain.User{
ID: uid,
Email: email,
EmailVerified: true,
Name: name,
Status: domain.UserStatusActive,
Roles: userRoles,
CreatedAt: now,
UpdatedAt: now,
}
r.passwords[uid] = hash
r.roles[uid] = userRoles
r.byEmail[email] = uid
}
func (r *UserRepository) copyUser(u *domain.User) *domain.User {
cp := *u
cp.Roles = make([]string, len(u.Roles))
copy(cp.Roles, u.Roles)
return &cp
}
func (r *UserRepository) Create(_ context.Context, user *domain.User) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.byEmail[user.Email]; exists {
return domain.ErrDuplicateEmail
}
r.users[user.ID] = r.copyUser(user)
r.byEmail[user.Email] = user.ID
r.roles[user.ID] = make([]string, len(user.Roles))
copy(r.roles[user.ID], user.Roles)
return nil
}
func (r *UserRepository) Get(_ context.Context, id domain.UserID) (*domain.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
u, ok := r.users[id]
if !ok {
return nil, domain.ErrUserNotFound
}
return r.copyUser(u), nil
}
func (r *UserRepository) GetByEmail(_ context.Context, email string) (*domain.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
uid, ok := r.byEmail[email]
if !ok {
return nil, domain.ErrUserNotFound
}
return r.copyUser(r.users[uid]), nil
}
func (r *UserRepository) Update(_ context.Context, user *domain.User) error {
r.mu.Lock()
defer r.mu.Unlock()
existing, ok := r.users[user.ID]
if !ok {
return domain.ErrUserNotFound
}
// If email changed, update the index.
if existing.Email != user.Email {
if _, taken := r.byEmail[user.Email]; taken {
return domain.ErrDuplicateEmail
}
delete(r.byEmail, existing.Email)
r.byEmail[user.Email] = user.ID
}
user.UpdatedAt = time.Now()
r.users[user.ID] = r.copyUser(user)
return nil
}
func (r *UserRepository) UpdateLastLogin(_ context.Context, id domain.UserID) error {
r.mu.Lock()
defer r.mu.Unlock()
u, ok := r.users[id]
if !ok {
return domain.ErrUserNotFound
}
now := time.Now()
u.LastLoginAt = &now
return nil
}
func (r *UserRepository) ExistsByEmail(_ context.Context, email string) (bool, error) {
r.mu.RLock()
defer r.mu.RUnlock()
_, ok := r.byEmail[email]
return ok, nil
}
func (r *UserRepository) SetPassword(_ context.Context, userID domain.UserID, hash string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.users[userID]; !ok {
return domain.ErrUserNotFound
}
r.passwords[userID] = hash
return nil
}
func (r *UserRepository) GetPasswordHash(_ context.Context, userID domain.UserID) (string, error) {
r.mu.RLock()
defer r.mu.RUnlock()
hash := r.passwords[userID]
return hash, nil
}
func (r *UserRepository) HasPassword(_ context.Context, userID domain.UserID) (bool, error) {
r.mu.RLock()
defer r.mu.RUnlock()
_, ok := r.passwords[userID]
return ok, nil
}
func (r *UserRepository) AddRole(_ context.Context, userID domain.UserID, role string) error {
r.mu.Lock()
defer r.mu.Unlock()
u, ok := r.users[userID]
if !ok {
return domain.ErrUserNotFound
}
for _, existing := range r.roles[userID] {
if existing == role {
return nil
}
}
r.roles[userID] = append(r.roles[userID], role)
u.Roles = make([]string, len(r.roles[userID]))
copy(u.Roles, r.roles[userID])
return nil
}
func (r *UserRepository) RemoveRole(_ context.Context, userID domain.UserID, role string) error {
r.mu.Lock()
defer r.mu.Unlock()
u, ok := r.users[userID]
if !ok {
return domain.ErrUserNotFound
}
filtered := make([]string, 0, len(r.roles[userID]))
for _, existing := range r.roles[userID] {
if existing != role {
filtered = append(filtered, existing)
}
}
r.roles[userID] = filtered
u.Roles = make([]string, len(filtered))
copy(u.Roles, filtered)
return nil
}
func (r *UserRepository) GetRoles(_ context.Context, userID domain.UserID) ([]string, error) {
r.mu.RLock()
defer r.mu.RUnlock()
if _, ok := r.users[userID]; !ok {
return nil, domain.ErrUserNotFound
}
roles := r.roles[userID]
result := make([]string, len(roles))
copy(result, roles)
return result, nil
}