Files
notely/backend/internal/application/services/note_service.go
2026-03-26 16:27:14 +00:00

428 lines
12 KiB
Go

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)
}