428 lines
12 KiB
Go
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)
|
|
}
|