Add Cloudflare email and collaboration workflows

This commit is contained in:
2026-07-22 12:59:16 -04:00
parent 09f51d1ef4
commit 4454e918c2
35 changed files with 1596 additions and 487 deletions

View File

@@ -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) {