first commit

This commit is contained in:
domrichardson
2026-03-24 16:03:04 +00:00
commit df40cc57e1
80 changed files with 16766 additions and 0 deletions

View File

@@ -0,0 +1,313 @@
package services
import (
"context"
"errors"
"strings"
"go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories"
)
// AdminService handles admin-level operations
type AdminService struct {
userRepo repositories.UserRepository
groupRepo repositories.GroupRepository
spaceRepo repositories.SpaceRepository
membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository
featureFlagRepo repositories.FeatureFlagRepository
permissionService *PermissionService
}
// NewAdminService creates a new AdminService
func NewAdminService(
userRepo repositories.UserRepository,
groupRepo repositories.GroupRepository,
spaceRepo repositories.SpaceRepository,
membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository,
featureFlagRepo repositories.FeatureFlagRepository,
permissionService *PermissionService,
) *AdminService {
return &AdminService{
userRepo: userRepo,
groupRepo: groupRepo,
spaceRepo: spaceRepo,
membershipRepo: membershipRepo,
noteRepo: noteRepo,
categoryRepo: categoryRepo,
featureFlagRepo: featureFlagRepo,
permissionService: permissionService,
}
}
// ListUsers returns all users as admin DTOs
func (s *AdminService) ListUsers(ctx context.Context) ([]*dto.AdminUserDTO, error) {
users, err := s.userRepo.ListAllUsers(ctx)
if err != nil {
return nil, err
}
result := make([]*dto.AdminUserDTO, len(users))
for i, u := range users {
if s.permissionService != nil {
permissions, err := s.permissionService.GetUserEffectivePermissions(ctx, u)
if err == nil {
u.Permissions = permissions
}
}
result[i] = dto.NewAdminUserDTO(u)
}
return result, nil
}
// ListGroups returns all permission groups.
func (s *AdminService) ListGroups(ctx context.Context) ([]*dto.PermissionGroupDTO, error) {
groups, err := s.groupRepo.ListGroups(ctx)
if err != nil {
return nil, err
}
result := make([]*dto.PermissionGroupDTO, len(groups))
for i, group := range groups {
result[i] = dto.NewPermissionGroupDTO(group)
}
return result, nil
}
// CreateGroup creates a new permission group.
func (s *AdminService) CreateGroup(ctx context.Context, req *dto.CreatePermissionGroupRequest) (*dto.PermissionGroupDTO, error) {
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, errors.New("group name is required")
}
group := &entities.PermissionGroup{
Name: name,
Description: strings.TrimSpace(req.Description),
Permissions: normalizePermissions(req.Permissions),
IsSystem: false,
}
if err := s.groupRepo.CreateGroup(ctx, group); err != nil {
return nil, err
}
return dto.NewPermissionGroupDTO(group), nil
}
// UpdateGroup updates a permission group.
func (s *AdminService) UpdateGroup(ctx context.Context, groupID bson.ObjectID, req *dto.UpdatePermissionGroupRequest) (*dto.PermissionGroupDTO, error) {
group, err := s.groupRepo.GetGroupByID(ctx, groupID)
if err != nil {
return nil, err
}
if group.IsSystem {
return nil, errors.New("system groups cannot be modified")
}
if name := strings.TrimSpace(req.Name); name != "" {
group.Name = name
}
group.Description = strings.TrimSpace(req.Description)
group.Permissions = normalizePermissions(req.Permissions)
if err := s.groupRepo.UpdateGroup(ctx, group); err != nil {
return nil, err
}
if err := s.refreshAllUserPermissions(ctx); err != nil {
return nil, err
}
return dto.NewPermissionGroupDTO(group), nil
}
// UpdateUserGroups assigns groups to a user.
func (s *AdminService) UpdateUserGroups(ctx context.Context, userID bson.ObjectID, groupIDs []bson.ObjectID) (*dto.AdminUserDTO, error) {
if s.permissionService == nil {
return nil, errors.New("permission service unavailable")
}
user, err := s.permissionService.SetUserGroups(ctx, userID, groupIDs)
if err != nil {
return nil, err
}
return dto.NewAdminUserDTO(user), nil
}
func (s *AdminService) refreshAllUserPermissions(ctx context.Context) error {
if s.permissionService == nil {
return nil
}
users, err := s.userRepo.ListAllUsers(ctx)
if err != nil {
return err
}
for _, user := range users {
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
return err
}
}
return nil
}
func normalizePermissions(permissions []string) []string {
unique := map[string]struct{}{}
result := make([]string, 0, len(permissions))
for _, permission := range permissions {
normalized := entities.NormalizePermission(permission)
if normalized == "" {
continue
}
if _, exists := unique[normalized]; exists {
continue
}
unique[normalized] = struct{}{}
result = append(result, normalized)
}
return result
}
// ListAllSpaces returns all spaces
func (s *AdminService) ListAllSpaces(ctx context.Context) ([]*dto.SpaceDTO, error) {
spaces, err := s.spaceRepo.GetAllSpaces(ctx)
if err != nil {
return nil, err
}
result := make([]*dto.SpaceDTO, len(spaces))
for i, space := range spaces {
result[i] = dto.NewSpaceDTO(space)
}
return result, nil
}
// UpdateSpace updates all editable space fields
func (s *AdminService) UpdateSpace(ctx context.Context, spaceID bson.ObjectID, req *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) {
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return nil, err
}
space.Name = req.Name
space.Description = req.Description
space.Icon = req.Icon
space.IsPublic = req.IsPublic
if err := s.spaceRepo.UpdateSpace(ctx, space); err != nil {
return nil, err
}
return dto.NewSpaceDTO(space), nil
}
// SetSpaceVisibility sets the is_public flag on a space
func (s *AdminService) SetSpaceVisibility(ctx context.Context, spaceID bson.ObjectID, isPublic bool) error {
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return err
}
space.IsPublic = isPublic
return s.spaceRepo.UpdateSpace(ctx, space)
}
// AddSpaceMember adds a member in a space if not already present.
func (s *AdminService) AddSpaceMember(ctx context.Context, spaceID, userID bson.ObjectID) error {
existing, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err == nil && existing != nil {
return nil
}
return s.membershipRepo.CreateMembership(ctx, &entities.Membership{
UserID: userID,
SpaceID: spaceID,
})
}
// ListSpaceMembers returns all members for a space
func (s *AdminService) ListSpaceMembers(ctx context.Context, spaceID bson.ObjectID) ([]*dto.SpaceMemberDTO, error) {
memberships, err := s.membershipRepo.GetSpaceMembers(ctx, spaceID)
if err != nil {
return nil, err
}
result := make([]*dto.SpaceMemberDTO, 0, len(memberships))
for _, member := range memberships {
username := member.UserID.Hex()
if user, err := s.userRepo.GetUserByID(ctx, member.UserID); err == nil {
username = user.Username
}
result = append(result, &dto.SpaceMemberDTO{
UserID: member.UserID.Hex(),
Username: username,
JoinedAt: member.JoinedAt.Format("2006-01-02T15:04:05Z"),
})
}
return result, nil
}
// RemoveSpaceMember removes a member from a space.
func (s *AdminService) RemoveSpaceMember(ctx context.Context, spaceID, userID bson.ObjectID) error {
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return err
}
return s.membershipRepo.DeleteMembership(ctx, membership.ID)
}
// DeleteSpace deletes a space and all associated data (admin, no permission check).
func (s *AdminService) DeleteSpace(ctx context.Context, spaceID bson.ObjectID) error {
if err := s.noteRepo.DeleteNotesBySpaceID(ctx, spaceID); err != nil {
return err
}
if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil {
return err
}
if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil {
return err
}
return s.spaceRepo.DeleteSpace(ctx, spaceID)
}
// GetFeatureFlags returns current app-wide feature flags.
func (s *AdminService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) {
if s.featureFlagRepo == nil {
return dto.NewFeatureFlagsDTO(nil), nil
}
flags, err := s.featureFlagRepo.GetFeatureFlags(ctx)
if err != nil {
return nil, err
}
return dto.NewFeatureFlagsDTO(flags), nil
}
// UpdateFeatureFlags updates app-wide feature flags.
func (s *AdminService) UpdateFeatureFlags(ctx context.Context, req *dto.UpdateFeatureFlagsRequest) (*dto.FeatureFlagsDTO, error) {
if s.featureFlagRepo == nil {
return nil, errors.New("feature flags are unavailable")
}
flags := &entities.FeatureFlags{
RegistrationEnabled: req.RegistrationEnabled,
ProviderLoginEnabled: req.ProviderLoginEnabled,
PublicSharingEnabled: req.PublicSharingEnabled,
}
if err := s.featureFlagRepo.UpdateFeatureFlags(ctx, flags); err != nil {
return nil, err
}
return dto.NewFeatureFlagsDTO(flags), nil
}

View File

@@ -0,0 +1,592 @@
package services
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories"
"github.com/noteapp/backend/internal/infrastructure/auth"
"github.com/noteapp/backend/internal/infrastructure/security"
"go.mongodb.org/mongo-driver/v2/bson"
"golang.org/x/oauth2"
)
// AuthService handles authentication operations
type AuthService struct {
userRepo repositories.UserRepository
groupRepo repositories.GroupRepository
providerRepo repositories.AuthProviderRepository
linkRepo repositories.UserProviderLinkRepository
recoveryRepo repositories.AccountRecoveryRepository
featureFlagRepo repositories.FeatureFlagRepository
permissionService *PermissionService
jwtManager *auth.JWTManager
passHasher *security.PasswordHasher
encryptor *security.Encryptor
}
// NewAuthService creates a new auth service
func NewAuthService(
userRepo repositories.UserRepository,
groupRepo repositories.GroupRepository,
providerRepo repositories.AuthProviderRepository,
linkRepo repositories.UserProviderLinkRepository,
recoveryRepo repositories.AccountRecoveryRepository,
featureFlagRepo repositories.FeatureFlagRepository,
permissionService *PermissionService,
jwtManager *auth.JWTManager,
passHasher *security.PasswordHasher,
encryptor *security.Encryptor,
) *AuthService {
return &AuthService{
userRepo: userRepo,
groupRepo: groupRepo,
providerRepo: providerRepo,
linkRepo: linkRepo,
recoveryRepo: recoveryRepo,
featureFlagRepo: featureFlagRepo,
permissionService: permissionService,
jwtManager: jwtManager,
passHasher: passHasher,
encryptor: encryptor,
}
}
// Register registers a new user
func (s *AuthService) Register(ctx context.Context, req *dto.RegisterRequest) (*dto.LoginResponse, error) {
flags, err := s.GetFeatureFlags(ctx)
if err != nil {
return nil, err
}
if !flags.RegistrationEnabled {
return nil, errors.New("registration is currently disabled")
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
req.Username = strings.TrimSpace(req.Username)
// Check if email already exists
_, err = s.userRepo.GetUserByEmail(ctx, req.Email)
if err == nil {
return nil, errors.New("email already registered")
}
// Check if username already exists
_, err = s.userRepo.GetUserByUsername(ctx, req.Username)
if err == nil {
return nil, errors.New("username already taken")
}
// Hash password
hashedPassword, err := s.passHasher.HashPassword(req.Password)
if err != nil {
return nil, err
}
// Create user
user := &entities.User{
Email: req.Email,
Username: req.Username,
PasswordHash: hashedPassword,
FirstName: req.FirstName,
LastName: req.LastName,
IsActive: true,
EmailVerified: false, // Should verify email in production
}
if err := s.userRepo.CreateUser(ctx, user); err != nil {
return nil, err
}
if s.permissionService != nil {
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
return nil, err
}
}
// Generate tokens
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
if err != nil {
return nil, err
}
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
if err != nil {
return nil, err
}
return &dto.LoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
User: dto.NewUserDTO(user),
ExpiresIn: 3600, // 1 hour
}, nil
}
// Login authenticates a user
func (s *AuthService) Login(ctx context.Context, req *dto.LoginRequest) (*dto.LoginResponse, error) {
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
// Get user by email
user, err := s.userRepo.GetUserByEmail(ctx, req.Email)
if err != nil {
return nil, errors.New("invalid credentials")
}
if !user.IsActive {
return nil, errors.New("account is inactive")
}
// Verify password
match, err := s.passHasher.VerifyPassword(req.Password, user.PasswordHash)
if err != nil || !match {
return nil, errors.New("invalid credentials")
}
// Update last login
now := time.Now()
user.LastLoginAt = &now
if s.permissionService != nil {
if err := s.permissionService.UpdateUserEffectivePermissions(ctx, user); err != nil {
return nil, err
}
}
if err := s.userRepo.UpdateUser(ctx, user); err != nil {
// Log error but don't fail the login
}
// Generate tokens
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
if err != nil {
return nil, err
}
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
if err != nil {
return nil, err
}
return &dto.LoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
User: dto.NewUserDTO(user),
ExpiresIn: 3600,
}, nil
}
// RefreshAccessToken refreshes an access token
func (s *AuthService) RefreshAccessToken(ctx context.Context, refreshToken string) (string, error) {
claims, err := s.jwtManager.VerifyRefreshToken(refreshToken)
if err != nil {
return "", err
}
user, err := s.userRepo.GetUserByID(ctx, mustParseObjectID(claims.UserID))
if err != nil {
return "", err
}
return s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
}
// RequestPasswordReset initiates password reset flow
func (s *AuthService) RequestPasswordReset(ctx context.Context, email string) error {
user, err := s.userRepo.GetUserByEmail(ctx, email)
if err != nil {
// Don't reveal if email exists (security best practice)
return nil
}
token, err := auth.GenerateRandomToken(32)
if err != nil {
return err
}
recovery := &entities.AccountRecovery{
UserID: user.ID,
Token: token,
Type: "password_reset",
ExpiresAt: time.Now().Add(1 * time.Hour),
}
// Save recovery token
// This would need AccountRecoveryRepository implementation
_ = recovery
// In production: send email with reset link containing token
return nil
}
// mustParseObjectID parses a string to ObjectID, panics on error
func mustParseObjectID(id string) bson.ObjectID {
objID, _ := bson.ObjectIDFromHex(id)
return objID
}
// ListProviders returns all active OAuth/OIDC providers.
func (s *AuthService) ListProviders(ctx context.Context) ([]*dto.AuthProviderDTO, error) {
flags, err := s.GetFeatureFlags(ctx)
if err != nil {
return nil, err
}
if !flags.ProviderLoginEnabled {
return []*dto.AuthProviderDTO{}, nil
}
if s.providerRepo == nil {
return []*dto.AuthProviderDTO{}, nil
}
providers, err := s.providerRepo.GetAllProviders(ctx)
if err != nil {
return nil, err
}
result := make([]*dto.AuthProviderDTO, 0, len(providers))
for _, provider := range providers {
result = append(result, dto.NewAuthProviderDTO(provider))
}
return result, nil
}
// GetFeatureFlags returns current app-wide feature flags.
func (s *AuthService) GetFeatureFlags(ctx context.Context) (*dto.FeatureFlagsDTO, error) {
if s.featureFlagRepo == nil {
return dto.NewFeatureFlagsDTO(nil), nil
}
flags, err := s.featureFlagRepo.GetFeatureFlags(ctx)
if err != nil {
return nil, err
}
return dto.NewFeatureFlagsDTO(flags), nil
}
// CreateProvider stores a new OAuth/OIDC provider.
func (s *AuthService) CreateProvider(ctx context.Context, req *dto.CreateAuthProviderRequest) (*dto.AuthProviderDTO, error) {
if s.providerRepo == nil || s.encryptor == nil {
return nil, errors.New("provider configuration unavailable")
}
providerType := strings.ToLower(strings.TrimSpace(req.Type))
if providerType != "oidc" && providerType != "oauth2" {
return nil, errors.New("provider type must be oidc or oauth2")
}
name := strings.TrimSpace(req.Name)
clientID := strings.TrimSpace(req.ClientID)
clientSecret := strings.TrimSpace(req.ClientSecret)
authorizationURL := strings.TrimSpace(req.AuthorizationURL)
tokenURL := strings.TrimSpace(req.TokenURL)
if name == "" || clientID == "" || clientSecret == "" || authorizationURL == "" || tokenURL == "" {
return nil, errors.New("missing required provider fields")
}
encryptedSecret, err := s.encryptor.Encrypt(clientSecret)
if err != nil {
return nil, err
}
provider := &entities.AuthProvider{
Name: name,
Type: providerType,
ClientID: clientID,
ClientSecret: encryptedSecret,
AuthorizationURL: authorizationURL,
TokenURL: tokenURL,
UserInfoURL: strings.TrimSpace(req.UserInfoURL),
Scopes: normalizeScopes(req.Scopes, providerType),
IDTokenClaim: strings.TrimSpace(req.IDTokenClaim),
IsActive: req.IsActive,
}
if err := s.providerRepo.CreateProvider(ctx, provider); err != nil {
return nil, err
}
return dto.NewAuthProviderDTO(provider), nil
}
// BuildProviderAuthorizationURL constructs a provider authorization URL.
func (s *AuthService) BuildProviderAuthorizationURL(ctx context.Context, providerID bson.ObjectID, redirectURI, state string) (string, error) {
flags, err := s.GetFeatureFlags(ctx)
if err != nil {
return "", err
}
if !flags.ProviderLoginEnabled {
return "", errors.New("provider login is currently disabled")
}
provider, secret, err := s.getProviderConfig(ctx, providerID)
if err != nil {
return "", err
}
config := oauth2.Config{
ClientID: provider.ClientID,
ClientSecret: secret,
RedirectURL: redirectURI,
Scopes: normalizeScopes(provider.Scopes, provider.Type),
Endpoint: oauth2.Endpoint{
AuthURL: provider.AuthorizationURL,
TokenURL: provider.TokenURL,
},
}
return config.AuthCodeURL(state, oauth2.AccessTypeOffline), nil
}
// CompleteProviderLogin exchanges an auth code and creates a user session.
func (s *AuthService) CompleteProviderLogin(ctx context.Context, providerID bson.ObjectID, code, redirectURI string) (*dto.LoginResponse, error) {
if s.providerRepo == nil || s.linkRepo == nil {
return nil, errors.New("provider login unavailable")
}
flags, err := s.GetFeatureFlags(ctx)
if err != nil {
return nil, err
}
if !flags.ProviderLoginEnabled {
return nil, errors.New("provider login is currently disabled")
}
provider, secret, err := s.getProviderConfig(ctx, providerID)
if err != nil {
return nil, err
}
config := oauth2.Config{
ClientID: provider.ClientID,
ClientSecret: secret,
RedirectURL: redirectURI,
Scopes: normalizeScopes(provider.Scopes, provider.Type),
Endpoint: oauth2.Endpoint{
AuthURL: provider.AuthorizationURL,
TokenURL: provider.TokenURL,
},
}
token, err := config.Exchange(ctx, code)
if err != nil {
return nil, err
}
profile, err := s.fetchProviderProfile(ctx, provider, token.AccessToken, token.Extra(provider.IDTokenClaim))
if err != nil {
return nil, err
}
user, err := s.findOrCreateOAuthUser(ctx, provider, profile)
if err != nil {
return nil, err
}
accessToken, err := s.jwtManager.GenerateAccessToken(user.ID.Hex(), user.Email, user.Username)
if err != nil {
return nil, err
}
refreshToken, err := s.jwtManager.GenerateRefreshToken(user.ID.Hex())
if err != nil {
return nil, err
}
return &dto.LoginResponse{AccessToken: accessToken, RefreshToken: refreshToken, User: dto.NewUserDTO(user), ExpiresIn: 3600}, nil
}
type providerProfile struct {
ProviderUserID string
Email string
Username string
FirstName string
LastName string
}
func (s *AuthService) getProviderConfig(ctx context.Context, providerID bson.ObjectID) (*entities.AuthProvider, string, error) {
provider, err := s.providerRepo.GetProviderByID(ctx, providerID)
if err != nil {
return nil, "", err
}
if !provider.IsActive {
return nil, "", errors.New("provider is inactive")
}
secret, err := s.encryptor.Decrypt(provider.ClientSecret)
if err != nil {
return nil, "", err
}
return provider, secret, nil
}
func (s *AuthService) fetchProviderProfile(ctx context.Context, provider *entities.AuthProvider, accessToken string, rawIDToken any) (*providerProfile, error) {
payload := map[string]any{}
if provider.UserInfoURL != "" {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, provider.UserInfoURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("provider userinfo request failed: %s", string(body))
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, err
}
} else if idToken, ok := rawIDToken.(string); ok && idToken != "" {
payload = decodeJWTWithoutVerify(idToken)
} else {
return nil, errors.New("provider must define userinfo_url or return id_token")
}
profile := &providerProfile{
ProviderUserID: firstNonEmpty(asString(payload["sub"]), asString(payload["id"]), asString(payload["user_id"])),
Email: strings.ToLower(strings.TrimSpace(firstNonEmpty(asString(payload["email"]), asString(payload["upn"])))),
Username: firstNonEmpty(asString(payload["preferred_username"]), asString(payload["login"]), asString(payload["name"])),
FirstName: firstNonEmpty(asString(payload["given_name"]), asString(payload["first_name"])),
LastName: firstNonEmpty(asString(payload["family_name"]), asString(payload["last_name"])),
}
if profile.ProviderUserID == "" {
return nil, errors.New("provider user info missing subject identifier")
}
if profile.Email == "" {
profile.Email = fmt.Sprintf("%s@%s.oauth.local", sanitizeUsername(profile.ProviderUserID), sanitizeUsername(provider.Name))
}
if profile.Username == "" {
profile.Username = strings.Split(profile.Email, "@")[0]
}
profile.Username = sanitizeUsername(profile.Username)
return profile, nil
}
func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider *entities.AuthProvider, profile *providerProfile) (*entities.User, error) {
if link, err := s.linkRepo.GetLinkByProviderUserID(ctx, provider.ID, profile.ProviderUserID); err == nil {
return s.userRepo.GetUserByID(ctx, link.UserID)
}
user, err := s.userRepo.GetUserByEmail(ctx, profile.Email)
if err != nil {
username, err := s.generateUniqueUsername(ctx, profile.Username)
if err != nil {
return nil, err
}
user = &entities.User{Email: profile.Email, Username: username, PasswordHash: "", FirstName: profile.FirstName, LastName: profile.LastName, IsActive: true, EmailVerified: true}
if err := s.userRepo.CreateUser(ctx, user); err != nil {
return nil, err
}
}
if _, err := s.linkRepo.GetLink(ctx, user.ID, provider.ID); err != nil {
if err := s.linkRepo.CreateLink(ctx, &entities.UserProviderLink{UserID: user.ID, ProviderID: provider.ID, ProviderUserID: profile.ProviderUserID, Email: profile.Email}); err != nil {
return nil, err
}
}
return user, nil
}
func (s *AuthService) generateUniqueUsername(ctx context.Context, base string) (string, error) {
base = sanitizeUsername(base)
candidates := []string{base}
for i := 0; i < 5; i++ {
token, err := auth.GenerateRandomToken(2)
if err != nil {
return "", err
}
candidates = append(candidates, fmt.Sprintf("%s-%s", base, token[:4]))
}
for _, candidate := range candidates {
if _, err := s.userRepo.GetUserByUsername(ctx, candidate); err != nil {
return candidate, nil
}
}
return fmt.Sprintf("%s-%d", base, time.Now().Unix()), nil
}
func normalizeScopes(scopes []string, providerType string) []string {
if len(scopes) == 0 {
if providerType == "oidc" {
return []string{"openid", "profile", "email"}
}
return []string{"profile", "email"}
}
result := make([]string, 0, len(scopes))
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope != "" {
result = append(result, scope)
}
}
return result
}
func decodeJWTWithoutVerify(token string) map[string]any {
parts := strings.Split(token, ".")
if len(parts) < 2 {
return map[string]any{}
}
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return map[string]any{}
}
claims := map[string]any{}
if err := json.Unmarshal(decoded, &claims); err != nil {
return map[string]any{}
}
return claims
}
func asString(value any) string {
if str, ok := value.(string); ok {
return strings.TrimSpace(str)
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func sanitizeUsername(value string) string {
cleaned := regexp.MustCompile(`[^a-zA-Z0-9_-]+`).ReplaceAllString(strings.ToLower(strings.TrimSpace(value)), "-")
cleaned = strings.Trim(cleaned, "-")
if cleaned == "" {
return "user"
}
return cleaned
}

View File

@@ -0,0 +1,283 @@
package services
import (
"context"
"errors"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories"
)
// CategoryService handles category operations
type CategoryService struct {
categoryRepo repositories.CategoryRepository
membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository
permissionService *PermissionService
}
// NewCategoryService creates a new category service
func NewCategoryService(
categoryRepo repositories.CategoryRepository,
membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository,
permissionService *PermissionService,
) *CategoryService {
return &CategoryService{
categoryRepo: categoryRepo,
membershipRepo: membershipRepo,
noteRepo: noteRepo,
permissionService: permissionService,
}
}
// CreateCategory creates a new category
func (s *CategoryService) CreateCategory(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateCategoryRequest) (*dto.CategoryDTO, error) {
// Verify user has access to space
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return nil, errors.New("unauthorized")
}
_ = membership
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.create")
if permErr != nil {
return nil, permErr
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
var parentID *bson.ObjectID
if req.ParentID != nil {
id, _ := bson.ObjectIDFromHex(*req.ParentID)
parentID = &id
// Verify parent category exists and belongs to same space
parent, err := s.categoryRepo.GetCategoryByID(ctx, id)
if err != nil || parent.SpaceID != spaceID {
return nil, errors.New("invalid parent category")
}
}
// Get next order value
categories, err := s.categoryRepo.GetCategoriesBySpaceID(ctx, spaceID)
order := len(categories)
category := &entities.Category{
SpaceID: spaceID,
Name: req.Name,
Description: req.Description,
ParentID: parentID,
Icon: req.Icon,
Order: order,
CreatedBy: userID,
UpdatedBy: userID,
}
if err := s.categoryRepo.CreateCategory(ctx, category); err != nil {
return nil, err
}
return dto.NewCategoryDTO(category), nil
}
// GetCategoryTree retrieves the full tree structure for a space
func (s *CategoryService) GetCategoryTree(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.CategoryTreeDTO, error) {
// Verify user has access to space
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return nil, errors.New("unauthorized")
}
// Get root categories
categories, err := s.categoryRepo.GetRootCategories(ctx, spaceID)
if err != nil {
return nil, err
}
var trees []*dto.CategoryTreeDTO
for _, category := range categories {
tree, err := s.buildCategoryTree(ctx, category, spaceID)
if err == nil {
trees = append(trees, tree)
}
}
return trees, nil
}
// buildCategoryTree recursively builds a category tree
func (s *CategoryService) buildCategoryTree(ctx context.Context, category *entities.Category, spaceID bson.ObjectID) (*dto.CategoryTreeDTO, error) {
tree := &dto.CategoryTreeDTO{
CategoryDTO: dto.NewCategoryDTO(category),
}
// Get subcategories
subcategories, err := s.categoryRepo.GetSubcategories(ctx, category.ID)
if err == nil {
for _, subcat := range subcategories {
subtree, err := s.buildCategoryTree(ctx, subcat, spaceID)
if err == nil {
tree.Subcategories = append(tree.Subcategories, subtree)
}
}
}
// Get notes in this category
notes, err := s.noteRepo.GetNotesByCategory(ctx, spaceID, category.ID)
if err == nil {
for _, note := range notes {
tree.Notes = append(tree.Notes, dto.NewNoteListItemDTO(note))
}
}
return tree, nil
}
// UpdateCategory updates a category
func (s *CategoryService) UpdateCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, req *dto.UpdateCategoryRequest) (*dto.CategoryDTO, error) {
// Verify user has access to space
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return nil, errors.New("unauthorized")
}
_ = membership
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.edit")
if permErr != nil {
return nil, permErr
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID)
if err != nil {
return nil, err
}
// Verify category belongs to this space
if category.SpaceID != spaceID {
return nil, errors.New("category not found in this space")
}
if req.Name != "" {
category.Name = req.Name
}
if req.Description != "" {
category.Description = req.Description
}
if req.Icon != "" {
category.Icon = req.Icon
}
category.UpdatedBy = userID
if err := s.categoryRepo.UpdateCategory(ctx, category); err != nil {
return nil, err
}
return dto.NewCategoryDTO(category), nil
}
// DeleteCategory deletes a category (and optionally move notes)
func (s *CategoryService) DeleteCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, moveNotesTo *string) error {
// Verify user has access to space
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return errors.New("unauthorized")
}
_ = membership
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.delete")
if permErr != nil {
return permErr
}
if !hasPermission {
return errors.New("insufficient permissions")
}
category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID)
if err != nil {
return err
}
// Verify category belongs to this space
if category.SpaceID != spaceID {
return errors.New("category not found in this space")
}
// Handle notes in this category
notes, err := s.noteRepo.GetNotesByCategory(ctx, spaceID, categoryID)
if err == nil {
for _, note := range notes {
if moveNotesTo != nil {
targetID, _ := bson.ObjectIDFromHex(*moveNotesTo)
note.CategoryID = &targetID
s.noteRepo.UpdateNote(ctx, note)
} else {
// Move to root (no category)
note.CategoryID = nil
s.noteRepo.UpdateNote(ctx, note)
}
}
}
return s.categoryRepo.DeleteCategory(ctx, categoryID)
}
// MoveCategory moves a category to a new parent
func (s *CategoryService) MoveCategory(ctx context.Context, categoryID, spaceID, userID bson.ObjectID, newParentID *string) (*dto.CategoryDTO, error) {
// Verify user has access to space
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return nil, errors.New("unauthorized")
}
_ = membership
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "category.edit")
if permErr != nil {
return nil, permErr
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
category, err := s.categoryRepo.GetCategoryByID(ctx, categoryID)
if err != nil {
return nil, err
}
// Verify category belongs to this space
if category.SpaceID != spaceID {
return nil, errors.New("category not found in this space")
}
// Validate new parent
if newParentID != nil {
parentID, _ := bson.ObjectIDFromHex(*newParentID)
parent, err := s.categoryRepo.GetCategoryByID(ctx, parentID)
if err != nil || parent.SpaceID != spaceID {
return nil, errors.New("invalid parent category")
}
category.ParentID = &parentID
} else {
category.ParentID = nil
}
category.UpdatedBy = userID
category.UpdatedAt = time.Now()
if err := s.categoryRepo.UpdateCategory(ctx, category); err != nil {
return nil, err
}
return dto.NewCategoryDTO(category), nil
}
func (s *CategoryService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
if s.permissionService == nil {
return false, errors.New("permission service unavailable")
}
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
}

View File

@@ -0,0 +1,427 @@
package services
import (
"context"
"errors"
"strings"
"time"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories"
"github.com/noteapp/backend/internal/infrastructure/security"
"go.mongodb.org/mongo-driver/v2/bson"
)
// NoteService handles note operations
type NoteService struct {
noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository
membershipRepo repositories.MembershipRepository
revisionRepo repositories.NoteRevisionRepository
spaceRepo repositories.SpaceRepository
permissionService *PermissionService
passwordHasher *security.PasswordHasher
}
// NewNoteService creates a new note service
func NewNoteService(
noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository,
membershipRepo repositories.MembershipRepository,
revisionRepo repositories.NoteRevisionRepository,
spaceRepo repositories.SpaceRepository,
permissionService *PermissionService,
passwordHasher *security.PasswordHasher,
) *NoteService {
return &NoteService{
noteRepo: noteRepo,
categoryRepo: categoryRepo,
membershipRepo: membershipRepo,
revisionRepo: revisionRepo,
spaceRepo: spaceRepo,
permissionService: permissionService,
passwordHasher: passwordHasher,
}
}
func (s *NoteService) toDisplayNoteDTO(note *entities.Note) *dto.NoteDTO {
noteDTO := dto.NewNoteDTO(note)
if note.IsPasswordProtected {
noteDTO.Content = ""
}
return noteDTO
}
// CreateNote creates a new note
func (s *NoteService) CreateNote(ctx context.Context, spaceID, userID bson.ObjectID, req *dto.CreateNoteRequest) (*dto.NoteDTO, error) {
// Verify user has access to space
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return nil, errors.New("unauthorized")
}
_ = membership
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.create")
if permErr != nil {
return nil, permErr
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
var categoryID *bson.ObjectID
if req.CategoryID != nil {
id, _ := bson.ObjectIDFromHex(*req.CategoryID)
categoryID = &id
}
note := &entities.Note{
SpaceID: spaceID,
CategoryID: categoryID,
Title: req.Title,
Description: req.Description,
Content: req.Content,
Tags: req.Tags,
IsPinned: req.IsPinned,
IsFavorite: req.IsFavorite,
IsPublic: req.IsPublic,
CreatedBy: userID,
UpdatedBy: userID,
}
notePassword := strings.TrimSpace(req.NotePassword)
if notePassword != "" {
if len(notePassword) < 4 {
return nil, errors.New("note password must be at least 4 characters")
}
if s.passwordHasher == nil {
return nil, errors.New("password hasher unavailable")
}
hash, err := s.passwordHasher.HashPassword(notePassword)
if err != nil {
return nil, err
}
note.PasswordHash = hash
note.IsPasswordProtected = true
}
if err := s.noteRepo.CreateNote(ctx, note); err != nil {
return nil, err
}
return dto.NewNoteDTO(note), nil
}
// GetNote retrieves a note (with space authorization check)
func (s *NoteService) GetNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID) (*dto.NoteDTO, error) {
// Verify user has access to space
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return nil, errors.New("unauthorized")
}
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil {
return nil, err
}
// Verify note belongs to this space
if note.SpaceID != spaceID {
return nil, errors.New("note not found in this space")
}
// Update viewed time
now := time.Now()
note.ViewedAt = &now
_ = s.noteRepo.UpdateNote(ctx, note)
return s.toDisplayNoteDTO(note), nil
}
// GetNotesBySpace retrieves notes in a space
func (s *NoteService) GetNotesBySpace(ctx context.Context, spaceID, userID bson.ObjectID, skip, limit int) ([]*dto.NoteDTO, error) {
// Verify user has access to space
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return nil, errors.New("unauthorized")
}
notes, err := s.noteRepo.GetNotesBySpaceID(ctx, spaceID, skip, limit)
if err != nil {
return nil, err
}
var noteDTOs []*dto.NoteDTO
for _, note := range notes {
noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note))
}
return noteDTOs, nil
}
// GetPublicNotesBySpace returns notes for a public space without requiring auth
func (s *NoteService) GetPublicNotesBySpace(ctx context.Context, spaceID bson.ObjectID, skip, limit int) ([]*dto.NoteDTO, error) {
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return nil, errors.New("space not found")
}
if !space.IsPublic {
return nil, errors.New("space is not public")
}
notes, err := s.noteRepo.GetPublicNotesBySpaceID(ctx, spaceID, skip, limit)
if err != nil {
return nil, err
}
var noteDTOs []*dto.NoteDTO
for _, note := range notes {
noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note))
}
return noteDTOs, nil
}
// GetPublicNoteBySpaceAndID returns a single public note in a public space without requiring auth
func (s *NoteService) GetPublicNoteBySpaceAndID(ctx context.Context, spaceID, noteID bson.ObjectID) (*dto.NoteDTO, error) {
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return nil, errors.New("space not found")
}
if !space.IsPublic {
return nil, errors.New("space is not public")
}
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil {
return nil, errors.New("note not found")
}
if note.SpaceID != spaceID {
return nil, errors.New("note not found")
}
if !note.IsPublic {
return nil, errors.New("note is not public")
}
return s.toDisplayNoteDTO(note), nil
}
// SearchNotes performs full-text search on notes in a space
func (s *NoteService) SearchNotes(ctx context.Context, spaceID, userID bson.ObjectID, query string) ([]*dto.NoteDTO, error) {
// Verify user has access to space
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return nil, errors.New("unauthorized")
}
notes, err := s.noteRepo.SearchNotes(ctx, spaceID, query)
if err != nil {
return nil, err
}
var noteDTOs []*dto.NoteDTO
for _, note := range notes {
noteDTOs = append(noteDTOs, s.toDisplayNoteDTO(note))
}
return noteDTOs, nil
}
// UpdateNote updates a note
func (s *NoteService) UpdateNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID, req *dto.UpdateNoteRequest) (*dto.NoteDTO, error) {
// Verify user has access to space
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return nil, errors.New("unauthorized")
}
_ = membership
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.edit")
if permErr != nil {
return nil, permErr
}
if !hasPermission {
return nil, errors.New("insufficient permissions")
}
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil {
return nil, err
}
// Verify note belongs to this space
if note.SpaceID != spaceID {
return nil, errors.New("note not found in this space")
}
// Create revision before updating
if s.revisionRepo != nil {
revision := &entities.NoteRevision{
NoteID: note.ID,
SpaceID: spaceID,
Title: note.Title,
Content: note.Content,
ChangedBy: userID,
CreatedAt: time.Now(),
}
_ = s.revisionRepo.CreateRevision(ctx, revision)
}
// Update fields
if req.Title != "" {
note.Title = req.Title
}
if req.Content != "" {
note.Content = req.Content
}
if req.Description != nil {
note.Description = *req.Description
}
if req.Tags != nil {
note.Tags = req.Tags
}
if req.CategoryID != nil {
id, _ := bson.ObjectIDFromHex(*req.CategoryID)
note.CategoryID = &id
}
if req.IsPinned != nil {
note.IsPinned = *req.IsPinned
}
if req.IsFavorite != nil {
note.IsFavorite = *req.IsFavorite
}
if req.IsPublic != nil {
note.IsPublic = *req.IsPublic
}
if req.NotePassword != nil {
if s.passwordHasher == nil {
return nil, errors.New("password hasher unavailable")
}
notePassword := strings.TrimSpace(*req.NotePassword)
if notePassword == "" {
note.PasswordHash = ""
note.IsPasswordProtected = false
} else {
if len(notePassword) < 4 {
return nil, errors.New("note password must be at least 4 characters")
}
hash, hashErr := s.passwordHasher.HashPassword(notePassword)
if hashErr != nil {
return nil, hashErr
}
note.PasswordHash = hash
note.IsPasswordProtected = true
}
}
note.UpdatedBy = userID
if err := s.noteRepo.UpdateNote(ctx, note); err != nil {
return nil, err
}
return dto.NewNoteDTO(note), nil
}
// UnlockNote verifies a protected note password and returns full note content for authenticated users.
func (s *NoteService) UnlockNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID, password string) (*dto.NoteDTO, error) {
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return nil, errors.New("unauthorized")
}
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil {
return nil, errors.New("note not found")
}
if note.SpaceID != spaceID {
return nil, errors.New("note not found in this space")
}
if !note.IsPasswordProtected {
return dto.NewNoteDTO(note), nil
}
if strings.TrimSpace(password) == "" {
return nil, errors.New("password is required")
}
if s.passwordHasher == nil {
return nil, errors.New("password hasher unavailable")
}
matched, verifyErr := s.passwordHasher.VerifyPassword(password, note.PasswordHash)
if verifyErr != nil || !matched {
return nil, errors.New("invalid note password")
}
now := time.Now()
note.ViewedAt = &now
_ = s.noteRepo.UpdateNote(ctx, note)
return dto.NewNoteDTO(note), nil
}
// UnlockPublicNote verifies a protected public note password and returns full note content.
func (s *NoteService) UnlockPublicNote(ctx context.Context, spaceID, noteID bson.ObjectID, password string) (*dto.NoteDTO, error) {
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return nil, errors.New("space not found")
}
if !space.IsPublic {
return nil, errors.New("space is not public")
}
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil {
return nil, errors.New("note not found")
}
if note.SpaceID != spaceID || !note.IsPublic {
return nil, errors.New("note not found")
}
if !note.IsPasswordProtected {
return dto.NewNoteDTO(note), nil
}
if strings.TrimSpace(password) == "" {
return nil, errors.New("password is required")
}
if s.passwordHasher == nil {
return nil, errors.New("password hasher unavailable")
}
matched, verifyErr := s.passwordHasher.VerifyPassword(password, note.PasswordHash)
if verifyErr != nil || !matched {
return nil, errors.New("invalid note password")
}
now := time.Now()
note.ViewedAt = &now
_ = s.noteRepo.UpdateNote(ctx, note)
return dto.NewNoteDTO(note), nil
}
// DeleteNote deletes a note
func (s *NoteService) DeleteNote(ctx context.Context, noteID, spaceID, userID bson.ObjectID) error {
// Verify user has access to space
membership, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID)
if err != nil {
return errors.New("unauthorized")
}
_ = membership
hasPermission, permErr := s.hasSpacePermission(ctx, userID, spaceID, "note.delete")
if permErr != nil {
return permErr
}
if !hasPermission {
return errors.New("insufficient permissions")
}
note, err := s.noteRepo.GetNoteByID(ctx, noteID)
if err != nil {
return err
}
// Verify note belongs to this space
if note.SpaceID != spaceID {
return errors.New("note not found in this space")
}
return s.noteRepo.DeleteNote(ctx, noteID)
}
func (s *NoteService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
if s.permissionService == nil {
return false, errors.New("permission service unavailable")
}
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
}

View File

@@ -0,0 +1,174 @@
package services
import (
"context"
"errors"
"strings"
"github.com/noteapp/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories"
"go.mongodb.org/mongo-driver/v2/bson"
)
const adminGroupName = "Admin"
// PermissionService resolves and checks user permissions.
type PermissionService struct {
userRepo repositories.UserRepository
groupRepo repositories.GroupRepository
membershipRepo repositories.MembershipRepository
spaceRepo repositories.SpaceRepository
}
// NewPermissionService creates a permission service.
func NewPermissionService(
userRepo repositories.UserRepository,
groupRepo repositories.GroupRepository,
membershipRepo repositories.MembershipRepository,
spaceRepo repositories.SpaceRepository,
) *PermissionService {
return &PermissionService{
userRepo: userRepo,
groupRepo: groupRepo,
membershipRepo: membershipRepo,
spaceRepo: spaceRepo,
}
}
// EnsureAdminGroup ensures the built-in Admin group exists with full wildcard access.
func (s *PermissionService) EnsureAdminGroup(ctx context.Context) error {
adminGroup, err := s.groupRepo.GetGroupByName(ctx, adminGroupName)
if err != nil {
adminGroup = &entities.PermissionGroup{
Name: adminGroupName,
Description: "System group with full access",
Permissions: []string{"*"},
IsSystem: true,
}
if createErr := s.groupRepo.CreateGroup(ctx, adminGroup); createErr != nil {
return createErr
}
}
return nil
}
// GetUserEffectivePermissions returns a deduplicated list of permissions for a user.
func (s *PermissionService) GetUserEffectivePermissions(ctx context.Context, user *entities.User) ([]string, error) {
granted := make(map[string]struct{})
groups, err := s.groupRepo.GetGroupsByIDs(ctx, user.GroupIDs)
if err != nil {
return nil, err
}
for _, group := range groups {
for _, permission := range group.Permissions {
normalized := entities.NormalizePermission(permission)
if normalized != "" {
granted[normalized] = struct{}{}
}
}
}
result := make([]string, 0, len(granted))
for permission := range granted {
result = append(result, permission)
}
return result, nil
}
// UpdateUserEffectivePermissions resolves and persists effective user permissions.
func (s *PermissionService) UpdateUserEffectivePermissions(ctx context.Context, user *entities.User) error {
permissions, err := s.GetUserEffectivePermissions(ctx, user)
if err != nil {
return err
}
user.Permissions = permissions
return s.userRepo.UpdateUser(ctx, user)
}
// SetUserGroups assigns groups to a user and refreshes permissions.
func (s *PermissionService) SetUserGroups(ctx context.Context, userID bson.ObjectID, groupIDs []bson.ObjectID) (*entities.User, error) {
user, err := s.userRepo.GetUserByID(ctx, userID)
if err != nil {
return nil, err
}
if len(groupIDs) > 0 {
groups, err := s.groupRepo.GetGroupsByIDs(ctx, groupIDs)
if err != nil {
return nil, err
}
if len(groups) != len(groupIDs) {
return nil, errors.New("one or more groups not found")
}
}
user.GroupIDs = dedupeObjectIDs(groupIDs)
if err := s.UpdateUserEffectivePermissions(ctx, user); err != nil {
return nil, err
}
return user, nil
}
// UserHasPermission checks if user has a concrete permission, supporting wildcards.
func (s *PermissionService) UserHasPermission(ctx context.Context, userID bson.ObjectID, permission string) (bool, error) {
user, err := s.userRepo.GetUserByID(ctx, userID)
if err != nil {
return false, err
}
return s.UserEntityHasPermission(ctx, user, permission)
}
// UserEntityHasPermission checks permission from a loaded user entity.
func (s *PermissionService) UserEntityHasPermission(ctx context.Context, user *entities.User, permission string) (bool, error) {
permission = entities.NormalizePermission(permission)
if permission == "" {
return false, nil
}
granted, err := s.GetUserEffectivePermissions(ctx, user)
if err != nil {
return false, err
}
for _, pattern := range granted {
if entities.PermissionMatches(pattern, permission) {
return true, nil
}
}
return false, nil
}
// HasSpacePermission checks a space-scoped permission action, like note.create.
func (s *PermissionService) HasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return false, err
}
action = strings.Trim(strings.ToLower(action), ". ")
if action == "" {
return false, nil
}
permission := "space." + entities.SpacePermissionToken(space.Name) + "." + action
return s.UserHasPermission(ctx, userID, permission)
}
func dedupeObjectIDs(ids []bson.ObjectID) []bson.ObjectID {
seen := map[bson.ObjectID]struct{}{}
result := make([]bson.ObjectID, 0, len(ids))
for _, id := range ids {
if id.IsZero() {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}

View File

@@ -0,0 +1,319 @@
package services
import (
"context"
"errors"
"time"
"github.com/noteapp/backend/internal/application/dto"
"github.com/noteapp/backend/internal/domain/entities"
"github.com/noteapp/backend/internal/domain/repositories"
"go.mongodb.org/mongo-driver/v2/bson"
)
// SpaceService handles space operations
type SpaceService struct {
spaceRepo repositories.SpaceRepository
membershipRepo repositories.MembershipRepository
noteRepo repositories.NoteRepository
categoryRepo repositories.CategoryRepository
userRepo repositories.UserRepository
permissionService *PermissionService
}
// NewSpaceService creates a new space service
func NewSpaceService(
spaceRepo repositories.SpaceRepository,
membershipRepo repositories.MembershipRepository,
noteRepo repositories.NoteRepository,
categoryRepo repositories.CategoryRepository,
userRepo repositories.UserRepository,
permissionService *PermissionService,
) *SpaceService {
return &SpaceService{
spaceRepo: spaceRepo,
membershipRepo: membershipRepo,
noteRepo: noteRepo,
categoryRepo: categoryRepo,
userRepo: userRepo,
permissionService: permissionService,
}
}
// GetPublicSpace returns a single publicly accessible space
func (s *SpaceService) GetPublicSpace(ctx context.Context, spaceID bson.ObjectID) (*dto.SpaceDTO, error) {
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return nil, err
}
if !space.IsPublic {
return nil, errors.New("space is not public")
}
return dto.NewSpaceDTO(space), nil
}
// GetPublicSpaces returns all publicly accessible spaces
func (s *SpaceService) GetPublicSpaces(ctx context.Context) ([]*dto.SpaceDTO, error) {
spaces, err := s.spaceRepo.GetPublicSpaces(ctx)
if err != nil {
return nil, err
}
result := make([]*dto.SpaceDTO, len(spaces))
for i, space := range spaces {
result[i] = dto.NewSpaceDTO(space)
}
return result, nil
}
// CreateSpace creates a new space owned by the user
func (s *SpaceService) CreateSpace(ctx context.Context, userID bson.ObjectID, req *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) {
if allowed, err := s.canCreateSpace(ctx, userID); err != nil {
return nil, err
} else if !allowed {
return nil, errors.New("insufficient permissions")
}
space := &entities.Space{
Name: req.Name,
Description: req.Description,
Icon: req.Icon,
OwnerID: userID,
IsPublic: req.IsPublic,
}
if err := s.spaceRepo.CreateSpace(ctx, space); err != nil {
return nil, err
}
// Add user as initial member
membership := &entities.Membership{
UserID: userID,
SpaceID: space.ID,
JoinedAt: time.Now(),
}
if err := s.membershipRepo.CreateMembership(ctx, membership); err != nil {
// Delete space if membership creation fails
s.spaceRepo.DeleteSpace(ctx, space.ID)
return nil, err
}
return dto.NewSpaceDTO(space), nil
}
// GetUserSpaces retrieves all spaces for a user
func (s *SpaceService) GetUserSpaces(ctx context.Context, userID bson.ObjectID) ([]*dto.SpaceDTO, error) {
// Get all memberships for the user
memberships, err := s.membershipRepo.GetUserMemberships(ctx, userID)
if err != nil {
return nil, err
}
var spaceDTOs []*dto.SpaceDTO
for _, membership := range memberships {
space, err := s.spaceRepo.GetSpaceByID(ctx, membership.SpaceID)
if err != nil {
continue // Skip spaces that can't be loaded
}
spaceDTOs = append(spaceDTOs, dto.NewSpaceDTO(space))
}
return spaceDTOs, nil
}
// GetSpaceByID gets a space by ID (with authorization check)
func (s *SpaceService) GetSpaceByID(ctx context.Context, spaceID, userID bson.ObjectID) (*dto.SpaceDTO, error) {
// Verify user has access to this space
if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil {
return nil, errors.New("unauthorized")
}
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return nil, err
}
return dto.NewSpaceDTO(space), nil
}
// UpdateSpace updates a space (owner only)
func (s *SpaceService) UpdateSpace(ctx context.Context, spaceID, userID bson.ObjectID, updates *dto.CreateSpaceRequest) (*dto.SpaceDTO, error) {
hasPermission, err := s.hasGlobalOrSpacePermission(ctx, userID, spaceID, "space.edit", "settings.edit")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("unauthorized")
}
space, err := s.spaceRepo.GetSpaceByID(ctx, spaceID)
if err != nil {
return nil, err
}
space.Name = updates.Name
space.Description = updates.Description
space.Icon = updates.Icon
space.IsPublic = updates.IsPublic
if err := s.spaceRepo.UpdateSpace(ctx, space); err != nil {
return nil, err
}
return dto.NewSpaceDTO(space), nil
}
// DeleteSpace deletes a space (owner only)
func (s *SpaceService) DeleteSpace(ctx context.Context, spaceID, userID bson.ObjectID) error {
hasPermission, err := s.hasGlobalOrSpacePermission(ctx, userID, spaceID, "space.delete", "settings.delete")
if err != nil {
return err
}
if !hasPermission {
return errors.New("unauthorized")
}
if err := s.noteRepo.DeleteNotesBySpaceID(ctx, spaceID); err != nil {
return err
}
if err := s.categoryRepo.DeleteCategoriesBySpaceID(ctx, spaceID); err != nil {
return err
}
if err := s.membershipRepo.DeleteMembershipsBySpaceID(ctx, spaceID); err != nil {
return err
}
return s.spaceRepo.DeleteSpace(ctx, spaceID)
}
// AddMember adds a member to a space.
func (s *SpaceService) AddMember(ctx context.Context, spaceID, userID, targetUserID bson.ObjectID) error {
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage")
if err != nil {
return err
}
if !hasPermission {
return errors.New("unauthorized")
}
// Create membership for target user
newMembership := &entities.Membership{
UserID: targetUserID,
SpaceID: spaceID,
JoinedAt: time.Now(),
InvitedBy: userID,
}
return s.membershipRepo.CreateMembership(ctx, newMembership)
}
// RemoveMember removes a member from a space (owner only)
func (s *SpaceService) RemoveMember(ctx context.Context, spaceID, userID, targetUserID bson.ObjectID) error {
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage")
if err != nil {
return err
}
if !hasPermission {
return errors.New("unauthorized")
}
// Get target membership
targetMembership, err := s.membershipRepo.GetUserMembership(ctx, targetUserID, spaceID)
if err != nil {
return err
}
return s.membershipRepo.DeleteMembership(ctx, targetMembership.ID)
}
// GetSpaceMembers returns all space members (owner only)
func (s *SpaceService) GetSpaceMembers(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.SpaceMemberDTO, error) {
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.view")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("unauthorized")
}
memberships, err := s.membershipRepo.GetSpaceMembers(ctx, spaceID)
if err != nil {
return nil, err
}
result := make([]*dto.SpaceMemberDTO, 0, len(memberships))
for _, member := range memberships {
username := member.UserID.Hex()
if user, err := s.userRepo.GetUserByID(ctx, member.UserID); err == nil {
username = user.Username
}
result = append(result, &dto.SpaceMemberDTO{
UserID: member.UserID.Hex(),
Username: username,
JoinedAt: member.JoinedAt.Format("2006-01-02T15:04:05Z"),
})
}
return result, nil
}
// ListAvailableUsers returns all users for member selection (owner only)
func (s *SpaceService) ListAvailableUsers(ctx context.Context, spaceID, userID bson.ObjectID) ([]*dto.UserOptionDTO, error) {
hasPermission, err := s.hasSpacePermission(ctx, userID, spaceID, "settings.member.manage")
if err != nil {
return nil, err
}
if !hasPermission {
return nil, errors.New("unauthorized")
}
users, err := s.userRepo.ListAllUsers(ctx)
if err != nil {
return nil, err
}
result := make([]*dto.UserOptionDTO, 0, len(users))
for _, user := range users {
result = append(result, &dto.UserOptionDTO{
ID: user.ID.Hex(),
Username: user.Username,
})
}
return result, nil
}
func (s *SpaceService) canCreateSpace(ctx context.Context, userID bson.ObjectID) (bool, error) {
if s.permissionService == nil {
return false, errors.New("permission service unavailable")
}
hasPermission, err := s.permissionService.UserHasPermission(ctx, userID, "space.create")
if err != nil {
return false, err
}
return hasPermission, nil
}
func (s *SpaceService) hasSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, action string) (bool, error) {
if s.permissionService == nil {
return false, nil
}
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, action)
}
func (s *SpaceService) hasGlobalOrSpacePermission(ctx context.Context, userID, spaceID bson.ObjectID, globalPermission, spaceAction string) (bool, error) {
if s.permissionService == nil {
return false, nil
}
hasGlobalPermission, err := s.permissionService.UserHasPermission(ctx, userID, globalPermission)
if err != nil {
return false, err
}
if hasGlobalPermission {
return true, nil
}
return s.permissionService.HasSpacePermission(ctx, userID, spaceID, spaceAction)
}