first commit
This commit is contained in:
313
backend/internal/application/services/admin_service.go
Normal file
313
backend/internal/application/services/admin_service.go
Normal 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
|
||||
}
|
||||
592
backend/internal/application/services/auth_service.go
Normal file
592
backend/internal/application/services/auth_service.go
Normal 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
|
||||
}
|
||||
283
backend/internal/application/services/category_service.go
Normal file
283
backend/internal/application/services/category_service.go
Normal 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)
|
||||
}
|
||||
427
backend/internal/application/services/note_service.go
Normal file
427
backend/internal/application/services/note_service.go
Normal 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)
|
||||
}
|
||||
174
backend/internal/application/services/permission_service.go
Normal file
174
backend/internal/application/services/permission_service.go
Normal 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
|
||||
}
|
||||
319
backend/internal/application/services/space_service.go
Normal file
319
backend/internal/application/services/space_service.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user