package services import ( "context" "errors" "strings" "time" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/application/dto" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/entities" "gitea.hostxtra.co.uk/mrhid6/notely/backend/internal/domain/repositories" "gitea.hostxtra.co.uk/mrhid6/notely/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) }