Files
cairnquire/apps/server/internal/collaboration/service.go
Tim Bendt 04a1f2bb6d ops design and app
add more comments feature, more tags feature, and other frontend updates
2026-06-09 18:27:59 -04:00

280 lines
7.7 KiB
Go

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