244 lines
6.2 KiB
Go
244 lines
6.2 KiB
Go
package memory
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.threesix.ai/jordan/persona-community-1/pkg/auth"
|
|
"git.threesix.ai/jordan/persona-community-1/services/persona-api/internal/domain"
|
|
"git.threesix.ai/jordan/persona-community-1/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
|
|
}
|