package collaboration import ( "context" "fmt" "log/slog" "time" "github.com/tim/cairnquire/apps/server/internal/auth" "github.com/tim/cairnquire/apps/server/internal/realtime" ) // EmailSender mirrors email.Sender without taking a direct dependency on the // email package (which would create a cycle once the queue imports from // collaboration's repository types). type EmailSender interface { Send(ctx context.Context, to []string, subject, body string) error } // EmailQueue is the minimal contract the collaboration service needs to // enqueue an outgoing email. The email.Queue type satisfies this. type EmailQueue interface { Enqueue(ctx context.Context, notificationID, to, subject, body string) (string, error) } // EmailRenderer renders a plain-text email for a notification kind. The // email.Render function satisfies this. type EmailRenderer interface { Render(kind string, data EmailData) (subject, body string, err error) } // EmailData is the email-package TemplateData projected through the // collaboration boundary to avoid an import cycle. type EmailData struct { ActorName string ActorEmail string DocumentPath string DocumentTitle string CommentBody string MentionText string ConflictDesc string ViewURL string UnsubURL string InstanceName string } // UserLookup resolves a user id to an email address and display name. The // auth.Repository satisfies this. type UserLookup interface { GetUserByID(ctx context.Context, userID string) (auth.User, 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 queue EmailQueue renderer EmailRenderer users UserLookup hub *realtime.Hub logger *slog.Logger publicOrigin string } type Options struct { EmailSender EmailSender EmailQueue EmailQueue EmailRenderer EmailRenderer UserLookup UserLookup PublicOrigin string } func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service { return NewServiceWithOptions(repo, hub, logger, Options{EmailSender: email}) } // NewServiceWithOptions constructs the service with email queue + renderer + // user lookup wiring. Callers that want email delivery should pass all of // EmailQueue, EmailRenderer, UserLookup; otherwise notifications stay // in-app only. func NewServiceWithOptions(repo *Repository, hub *realtime.Hub, logger *slog.Logger, opts Options) *Service { if opts.EmailSender == nil { opts.EmailSender = &NoOpEmailSender{} } return &Service{ repo: repo, email: opts.EmailSender, queue: opts.EmailQueue, renderer: opts.EmailRenderer, users: opts.UserLookup, hub: hub, logger: logger, publicOrigin: opts.PublicOrigin, } } // 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 } s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "file_changed", EmailData{ ActorName: actorName, DocumentPath: documentPath, ViewURL: s.documentURL(documentPath), }) } // 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) continue } s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{ ActorName: comment.AuthorName, CommentBody: comment.Content, ViewURL: s.documentURL(comment.DocumentID), }) } return nil } // enqueueNotificationEmail resolves the watcher's email address, renders the // template, and enqueues a durable email. Failures are logged and swallowed // so a bad email config never blocks the in-app notification path. func (s *Service) enqueueNotificationEmail(ctx context.Context, notificationID, userID, kind string, data EmailData) { if s.queue == nil || s.renderer == nil || s.users == nil { return } user, err := s.users.GetUserByID(ctx, userID) if err != nil { s.logger.Warn("email: lookup user", "user_id", userID, "error", err) return } if user.Email == "" || user.Disabled { return } if data.ActorName == "" { data.ActorName = user.DisplayName } subject, body, err := s.renderer.Render(kind, data) if err != nil { s.logger.Warn("email: render", "kind", kind, "error", err) return } if _, err := s.queue.Enqueue(ctx, notificationID, user.Email, subject, body); err != nil { s.logger.Warn("email: enqueue", "user_id", userID, "error", err) } } func (s *Service) documentURL(documentPath string) string { if s.publicOrigin == "" { return "" } return s.publicOrigin + "/" + documentPath } 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 } }