package collaboration import ( "context" "fmt" "log/slog" "time" "github.com/tim/cairnquire/apps/server/internal/auth" "github.com/tim/cairnquire/apps/server/internal/realtime" ) type EmailSender interface { Send(ctx context.Context, to []string, subject, body string) error } type NoOpEmailSender struct{} func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error { slog.Info("email stub", "to", to, "subject", subject, "body_len", len(body)) return nil } type Service struct { repo *Repository email EmailSender hub *realtime.Hub logger *slog.Logger } func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service { if email == nil { email = &NoOpEmailSender{} } return &Service{ repo: repo, email: email, hub: hub, logger: logger, } } // Comments func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, input CreateCommentInput) (*Comment, error) { if principal.UserID == "" { return nil, fmt.Errorf("authentication required") } if input.Content == "" { return nil, fmt.Errorf("content is required") } if input.DocumentID == "" { return nil, fmt.Errorf("document_id is required") } comment := Comment{ ID: randomID("comment"), DocumentID: input.DocumentID, VersionHash: input.VersionHash, AuthorID: principal.UserID, Content: input.Content, CreatedAt: time.Now().UTC(), } if input.ParentID != nil && *input.ParentID != "" { comment.ParentID = input.ParentID } if input.AnchorHash != nil && *input.AnchorHash != "" { comment.AnchorHash = input.AnchorHash } if input.AnchorLine != nil && *input.AnchorLine > 0 { comment.AnchorLine = input.AnchorLine } if err := s.repo.CreateComment(ctx, comment); err != nil { return nil, err } // Create notifications for watchers if err := s.notifyWatchersOfComment(ctx, comment); err != nil { s.logger.Warn("failed to notify watchers of comment", "error", err) } // Broadcast to connected clients s.broadcastNotification(principal.UserID) return s.repo.GetComment(ctx, comment.ID) } func (s *Service) ListComments(ctx context.Context, documentID string) ([]Comment, error) { return s.repo.ListCommentsByDocument(ctx, documentID) } func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) { return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash) } func (s *Service) GetComment(ctx context.Context, commentID string) (*Comment, error) { return s.repo.GetComment(ctx, commentID) } func (s *Service) ResolveComment(ctx context.Context, principal auth.Principal, commentID string) error { if principal.UserID == "" { return fmt.Errorf("authentication required") } return s.repo.ResolveComment(ctx, commentID, principal.UserID) } // Notifications func (s *Service) ListNotifications(ctx context.Context, principal auth.Principal, unreadOnly bool, limit int) ([]Notification, error) { if principal.UserID == "" { return nil, fmt.Errorf("authentication required") } return s.repo.ListNotifications(ctx, principal.UserID, unreadOnly, limit) } func (s *Service) GetBellStatus(ctx context.Context, principal auth.Principal) (*NotificationBell, error) { if principal.UserID == "" { return &NotificationBell{UnreadCount: 0, Items: []Notification{}}, nil } count, err := s.repo.CountUnreadNotifications(ctx, principal.UserID) if err != nil { return nil, err } items, err := s.repo.ListNotifications(ctx, principal.UserID, false, 20) if err != nil { return nil, err } return &NotificationBell{ UnreadCount: count, Items: items, }, nil } func (s *Service) MarkNotificationRead(ctx context.Context, principal auth.Principal, notificationID string) error { if principal.UserID == "" { return fmt.Errorf("authentication required") } return s.repo.MarkNotificationRead(ctx, notificationID, principal.UserID) } func (s *Service) MarkAllNotificationsRead(ctx context.Context, principal auth.Principal) error { if principal.UserID == "" { return fmt.Errorf("authentication required") } return s.repo.MarkAllNotificationsRead(ctx, principal.UserID) } // Watchers func (s *Service) WatchDocument(ctx context.Context, principal auth.Principal, documentID string) error { if principal.UserID == "" { return fmt.Errorf("authentication required") } return s.repo.AddWatcher(ctx, Watcher{ UserID: principal.UserID, DocumentID: &documentID, CreatedAt: time.Now().UTC(), }) } func (s *Service) UnwatchDocument(ctx context.Context, principal auth.Principal, documentID string) error { if principal.UserID == "" { return fmt.Errorf("authentication required") } return s.repo.RemoveWatcher(ctx, principal.UserID, &documentID, nil) } func (s *Service) WatchFolder(ctx context.Context, principal auth.Principal, folderPath string) error { if principal.UserID == "" { return fmt.Errorf("authentication required") } return s.repo.AddWatcher(ctx, Watcher{ UserID: principal.UserID, FolderPath: &folderPath, CreatedAt: time.Now().UTC(), }) } func (s *Service) UnwatchFolder(ctx context.Context, principal auth.Principal, folderPath string) error { if principal.UserID == "" { return fmt.Errorf("authentication required") } return s.repo.RemoveWatcher(ctx, principal.UserID, nil, &folderPath) } func (s *Service) IsWatching(ctx context.Context, principal auth.Principal, documentID string) (bool, error) { if principal.UserID == "" { return false, nil } return s.repo.IsWatching(ctx, principal.UserID, documentID) } // Document change notifications func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string, documentPath string, actorName string) error { watchers, err := s.repo.ListWatchersByDocument(ctx, documentID) if err != nil { return fmt.Errorf("list document watchers: %w", err) } folderWatchers, err := s.repo.ListWatchersByFolder(ctx, documentPath) if err != nil { return fmt.Errorf("list folder watchers: %w", err) } seen := make(map[string]bool) for _, w := range append(watchers, folderWatchers...) { if seen[w.UserID] { continue } seen[w.UserID] = true msg := fmt.Sprintf("%s updated %s", actorName, documentPath) notification := Notification{ ID: randomID("notif"), UserID: w.UserID, Type: "file_changed", ResourceType: "document", ResourceID: documentID, Message: msg, CreatedAt: time.Now().UTC(), } if err := s.repo.CreateNotification(ctx, notification); err != nil { s.logger.Warn("create notification", "error", err) continue } } // Broadcast bell update to all connected clients s.broadcastNotification("") return nil } func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) error { watchers, err := s.repo.ListWatchersByDocument(ctx, comment.DocumentID) if err != nil { return err } for _, w := range watchers { if w.UserID == comment.AuthorID { continue } msg := fmt.Sprintf("New comment on document") if comment.AnchorHash != nil { msg = fmt.Sprintf("New comment on a section of the document") } notification := Notification{ ID: randomID("notif"), UserID: w.UserID, Type: "comment", ResourceType: "document", ResourceID: comment.DocumentID, Message: msg, CreatedAt: time.Now().UTC(), } if err := s.repo.CreateNotification(ctx, notification); err != nil { s.logger.Warn("create comment notification", "error", err) } } return nil } func (s *Service) broadcastNotification(userID string) { if s.hub == nil { return } s.hub.Broadcast(realtime.Event{ Type: "notification", Data: map[string]string{"userId": userID}, }) } func (s *Service) SetEmailSender(email EmailSender) { if email != nil { s.email = email } }