Add Cloudflare email and collaboration workflows
This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||
@@ -50,6 +52,8 @@ type UserLookup interface {
|
||||
GetUserByID(ctx context.Context, userID string) (auth.User, error)
|
||||
}
|
||||
|
||||
type DocumentReadAuthorizer func(ctx context.Context, userID, documentID string) (bool, error)
|
||||
|
||||
type NoOpEmailSender struct{}
|
||||
|
||||
func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||
@@ -58,22 +62,24 @@ func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body s
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo *Repository
|
||||
email EmailSender
|
||||
queue EmailQueue
|
||||
renderer EmailRenderer
|
||||
users UserLookup
|
||||
hub *realtime.Hub
|
||||
logger *slog.Logger
|
||||
publicOrigin string
|
||||
repo *Repository
|
||||
email EmailSender
|
||||
queue EmailQueue
|
||||
renderer EmailRenderer
|
||||
users UserLookup
|
||||
canReadDocument DocumentReadAuthorizer
|
||||
hub *realtime.Hub
|
||||
logger *slog.Logger
|
||||
publicOrigin string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
EmailSender EmailSender
|
||||
EmailQueue EmailQueue
|
||||
EmailRenderer EmailRenderer
|
||||
UserLookup UserLookup
|
||||
PublicOrigin string
|
||||
EmailSender EmailSender
|
||||
EmailQueue EmailQueue
|
||||
EmailRenderer EmailRenderer
|
||||
UserLookup UserLookup
|
||||
CanReadDocument DocumentReadAuthorizer
|
||||
PublicOrigin string
|
||||
}
|
||||
|
||||
func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service {
|
||||
@@ -89,14 +95,15 @@ func NewServiceWithOptions(repo *Repository, hub *realtime.Hub, logger *slog.Log
|
||||
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,
|
||||
repo: repo,
|
||||
email: opts.EmailSender,
|
||||
queue: opts.EmailQueue,
|
||||
renderer: opts.EmailRenderer,
|
||||
users: opts.UserLookup,
|
||||
canReadDocument: opts.CanReadDocument,
|
||||
hub: hub,
|
||||
logger: logger,
|
||||
publicOrigin: opts.PublicOrigin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,12 +113,35 @@ func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, i
|
||||
if principal.UserID == "" {
|
||||
return nil, fmt.Errorf("authentication required")
|
||||
}
|
||||
input.Content = strings.TrimSpace(input.Content)
|
||||
if input.Content == "" {
|
||||
return nil, fmt.Errorf("content is required")
|
||||
}
|
||||
if input.DocumentID == "" {
|
||||
return nil, fmt.Errorf("document_id is required")
|
||||
}
|
||||
if input.VersionHash == "" {
|
||||
return nil, fmt.Errorf("version_hash is required")
|
||||
}
|
||||
|
||||
if input.ParentID != nil && *input.ParentID != "" {
|
||||
parent, err := s.repo.GetComment(ctx, *input.ParentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parent comment not found")
|
||||
}
|
||||
if parent.DocumentID != input.DocumentID {
|
||||
return nil, fmt.Errorf("parent comment belongs to another document")
|
||||
}
|
||||
if parent.ResolvedAt != nil {
|
||||
return nil, fmt.Errorf("cannot reply to a resolved thread")
|
||||
}
|
||||
if input.AnchorHash == nil {
|
||||
input.AnchorHash = parent.AnchorHash
|
||||
}
|
||||
if input.AnchorLine == nil {
|
||||
input.AnchorLine = parent.AnchorLine
|
||||
}
|
||||
}
|
||||
|
||||
comment := Comment{
|
||||
ID: randomID("comment"),
|
||||
@@ -135,23 +165,27 @@ func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, i
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create notifications for watchers
|
||||
if err := s.notifyWatchersOfComment(ctx, comment); err != nil {
|
||||
created, err := s.repo.GetComment(ctx, comment.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.notifyWatchersOfComment(ctx, *created); 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)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListComments(ctx context.Context, documentID string) ([]Comment, error) {
|
||||
return s.repo.ListCommentsByDocument(ctx, documentID)
|
||||
func (s *Service) ListComments(ctx context.Context, documentID string, includeResolved bool) ([]Comment, error) {
|
||||
return s.repo.ListCommentsByDocument(ctx, documentID, includeResolved)
|
||||
}
|
||||
|
||||
func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) {
|
||||
return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash)
|
||||
func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string, includeResolved bool) ([]Comment, error) {
|
||||
return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash, includeResolved)
|
||||
}
|
||||
|
||||
func (s *Service) GetComment(ctx context.Context, commentID string) (*Comment, error) {
|
||||
@@ -162,7 +196,14 @@ func (s *Service) ResolveComment(ctx context.Context, principal auth.Principal,
|
||||
if principal.UserID == "" {
|
||||
return fmt.Errorf("authentication required")
|
||||
}
|
||||
return s.repo.ResolveComment(ctx, commentID, principal.UserID)
|
||||
comment, err := s.repo.GetComment(ctx, commentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("comment not found")
|
||||
}
|
||||
if comment.ParentID != nil {
|
||||
return fmt.Errorf("only a thread root can be resolved")
|
||||
}
|
||||
return s.repo.ResolveCommentThread(ctx, commentID, principal.UserID)
|
||||
}
|
||||
|
||||
// Notifications
|
||||
@@ -212,6 +253,9 @@ func (s *Service) WatchDocument(ctx context.Context, principal auth.Principal, d
|
||||
if principal.UserID == "" {
|
||||
return fmt.Errorf("authentication required")
|
||||
}
|
||||
if strings.TrimSpace(documentID) == "" {
|
||||
return fmt.Errorf("document_id is required")
|
||||
}
|
||||
return s.repo.AddWatcher(ctx, Watcher{
|
||||
UserID: principal.UserID,
|
||||
DocumentID: &documentID,
|
||||
@@ -223,6 +267,9 @@ func (s *Service) UnwatchDocument(ctx context.Context, principal auth.Principal,
|
||||
if principal.UserID == "" {
|
||||
return fmt.Errorf("authentication required")
|
||||
}
|
||||
if strings.TrimSpace(documentID) == "" {
|
||||
return fmt.Errorf("document_id is required")
|
||||
}
|
||||
return s.repo.RemoveWatcher(ctx, principal.UserID, &documentID, nil)
|
||||
}
|
||||
|
||||
@@ -230,6 +277,10 @@ func (s *Service) WatchFolder(ctx context.Context, principal auth.Principal, fol
|
||||
if principal.UserID == "" {
|
||||
return fmt.Errorf("authentication required")
|
||||
}
|
||||
folderPath, err := NormalizeFolderPath(folderPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.AddWatcher(ctx, Watcher{
|
||||
UserID: principal.UserID,
|
||||
FolderPath: &folderPath,
|
||||
@@ -241,6 +292,10 @@ func (s *Service) UnwatchFolder(ctx context.Context, principal auth.Principal, f
|
||||
if principal.UserID == "" {
|
||||
return fmt.Errorf("authentication required")
|
||||
}
|
||||
folderPath, err := NormalizeFolderPath(folderPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.RemoveWatcher(ctx, principal.UserID, nil, &folderPath)
|
||||
}
|
||||
|
||||
@@ -269,6 +324,9 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string,
|
||||
continue
|
||||
}
|
||||
seen[w.UserID] = true
|
||||
if !s.watcherCanRead(ctx, w.UserID, documentID) {
|
||||
continue
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("%s updated %s", actorName, documentPath)
|
||||
notification := Notification{
|
||||
@@ -298,15 +356,23 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string,
|
||||
}
|
||||
|
||||
func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) error {
|
||||
watchers, err := s.repo.ListWatchersByDocument(ctx, comment.DocumentID)
|
||||
watchers, err := s.repo.ListWatchersForDocument(ctx, comment.DocumentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
for _, w := range watchers {
|
||||
if w.UserID == comment.AuthorID {
|
||||
continue
|
||||
}
|
||||
if seen[w.UserID] {
|
||||
continue
|
||||
}
|
||||
seen[w.UserID] = true
|
||||
if !s.watcherCanRead(ctx, w.UserID, comment.DocumentID) {
|
||||
continue
|
||||
}
|
||||
msg := fmt.Sprintf("New comment on document")
|
||||
if comment.AnchorHash != nil {
|
||||
msg = fmt.Sprintf("New comment on a section of the document")
|
||||
@@ -325,9 +391,10 @@ func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment)
|
||||
continue
|
||||
}
|
||||
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{
|
||||
ActorName: comment.AuthorName,
|
||||
CommentBody: comment.Content,
|
||||
ViewURL: s.documentURL(comment.DocumentID),
|
||||
ActorName: comment.AuthorName,
|
||||
DocumentPath: documentPathFromID(comment.DocumentID) + ".md",
|
||||
CommentBody: comment.Content,
|
||||
ViewURL: s.documentURL(comment.DocumentID),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
@@ -349,7 +416,7 @@ func (s *Service) enqueueNotificationEmail(ctx context.Context, notificationID,
|
||||
return
|
||||
}
|
||||
if data.ActorName == "" {
|
||||
data.ActorName = user.DisplayName
|
||||
data.ActorName = "Someone"
|
||||
}
|
||||
subject, body, err := s.renderer.Render(kind, data)
|
||||
if err != nil {
|
||||
@@ -365,7 +432,25 @@ func (s *Service) documentURL(documentPath string) string {
|
||||
if s.publicOrigin == "" {
|
||||
return ""
|
||||
}
|
||||
return s.publicOrigin + "/" + documentPath
|
||||
clean := strings.TrimPrefix(documentPath, "doc:")
|
||||
clean = strings.TrimSuffix(strings.TrimPrefix(clean, "/"), ".md")
|
||||
parts := strings.Split(clean, "/")
|
||||
for i, part := range parts {
|
||||
parts[i] = url.PathEscape(part)
|
||||
}
|
||||
return strings.TrimRight(s.publicOrigin, "/") + "/docs/" + strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func (s *Service) watcherCanRead(ctx context.Context, userID, documentID string) bool {
|
||||
if s.canReadDocument == nil {
|
||||
return true
|
||||
}
|
||||
allowed, err := s.canReadDocument(ctx, userID, documentID)
|
||||
if err != nil {
|
||||
s.logger.Warn("authorize watcher", "user_id", userID, "document_id", documentID, "error", err)
|
||||
return false
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func (s *Service) broadcastNotification(userID string) {
|
||||
|
||||
Reference in New Issue
Block a user