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 }