package store import ( "context" "errors" "fmt" "strings" "time" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "bountyboard/internal/domain" ) // ErrDuplicate is returned when a unique index rejects an insert/update // (e.g. email already registered). var ErrDuplicate = errors.New("duplicate value") func (s *Store) users() *mongo.Collection { return s.DB.Collection("users") } // NormalizeEmail is the canonical form stored and queried everywhere. func NormalizeEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } func (s *Store) CreateUser(ctx context.Context, u *domain.User) error { now := time.Now().UTC() u.Email = NormalizeEmail(u.Email) u.CreatedAt, u.UpdatedAt, u.Version = now, now, 1 if u.LastSeenAt.IsZero() { u.LastSeenAt = now } if _, err := s.users().InsertOne(ctx, u); err != nil { if mongo.IsDuplicateKeyError(err) { return fmt.Errorf("user %s: %w", u.Email, ErrDuplicate) } return fmt.Errorf("insert user: %w", err) } return nil } func (s *Store) userBy(ctx context.Context, filter bson.M) (*domain.User, error) { var u domain.User err := s.users().FindOne(ctx, filter).Decode(&u) if errors.Is(err, mongo.ErrNoDocuments) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("find user: %w", err) } return &u, nil } func (s *Store) UserByID(ctx context.Context, id string) (*domain.User, error) { return s.userBy(ctx, bson.M{"_id": id}) } func (s *Store) UserByEmail(ctx context.Context, email string) (*domain.User, error) { return s.userBy(ctx, bson.M{"email": NormalizeEmail(email)}) } func (s *Store) UserByOIDC(ctx context.Context, issuer, subject string) (*domain.User, error) { return s.userBy(ctx, bson.M{"auth.oidc.issuer": issuer, "auth.oidc.subject": subject}) } func (s *Store) CountUsers(ctx context.Context) (int64, error) { n, err := s.users().CountDocuments(ctx, bson.M{}) if err != nil { return 0, fmt.Errorf("count users: %w", err) } return n, nil } // SetPassword replaces the local credential and clears/sets the forced // change flag. Not version-checked: password changes must never lose to a // concurrent profile edit. func (s *Store) SetPassword(ctx context.Context, userID, hash string, mustChange bool) error { res, err := s.users().UpdateOne(ctx, bson.M{"_id": userID}, bson.M{ "$set": bson.M{ "auth.local.passwordHash": hash, "auth.local.mustChange": mustChange, "updatedAt": time.Now().UTC(), }, "$inc": bson.M{"version": 1}, }) if err != nil { return fmt.Errorf("set password: %w", err) } if res.MatchedCount == 0 { return fmt.Errorf("user %s: %w", userID, ErrNotFound) } return nil } // LinkOIDC attaches an OIDC identity to an existing account (login linking // by verified email, ยง7). func (s *Store) LinkOIDC(ctx context.Context, userID, issuer, subject string) error { res, err := s.users().UpdateOne(ctx, bson.M{"_id": userID}, bson.M{ "$set": bson.M{ "auth.oidc": domain.OIDCAuth{Issuer: issuer, Subject: subject}, "updatedAt": time.Now().UTC(), }, "$inc": bson.M{"version": 1}, }) if err != nil { if mongo.IsDuplicateKeyError(err) { return fmt.Errorf("oidc identity: %w", ErrDuplicate) } return fmt.Errorf("link oidc: %w", err) } if res.MatchedCount == 0 { return fmt.Errorf("user %s: %w", userID, ErrNotFound) } return nil } // ListAdminIDs returns ids of all active admins (system notifications). func (s *Store) ListAdminIDs(ctx context.Context) ([]string, error) { cur, err := s.users().Find(ctx, bson.M{"roles.admin": true, "disabled": false}) if err != nil { return nil, fmt.Errorf("list admins: %w", err) } var users []domain.User if err := cur.All(ctx, &users); err != nil { return nil, fmt.Errorf("decode admins: %w", err) } ids := make([]string, len(users)) for i, u := range users { ids[i] = u.ID } return ids, nil } // TouchLastSeen updates lastSeenAt without bumping version (pure telemetry). func (s *Store) TouchLastSeen(ctx context.Context, userID string) error { _, err := s.users().UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$set": bson.M{"lastSeenAt": time.Now().UTC()}}) if err != nil { return fmt.Errorf("touch lastSeenAt: %w", err) } return nil }