386 lines
11 KiB
Go
386 lines
11 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"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
}
|