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

@@ -63,14 +63,17 @@ func (r *Repository) GetComment(ctx context.Context, id string) (*Comment, error
return &c, nil
}
func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string) ([]Comment, error) {
rows, err := r.db.QueryContext(ctx, `
func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string, includeResolved bool) ([]Comment, error) {
query := `
SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by
FROM comments c
JOIN users u ON u.id = c.author_id
WHERE c.document_id = ?
ORDER BY c.created_at ASC
`, documentID)
WHERE c.document_id = ?`
if !includeResolved {
query += ` AND c.resolved_at IS NULL`
}
query += ` ORDER BY c.created_at ASC`
rows, err := r.db.QueryContext(ctx, query, documentID)
if err != nil {
return nil, fmt.Errorf("list comments: %w", err)
}
@@ -79,14 +82,17 @@ func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID stri
return scanComments(rows)
}
func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) {
rows, err := r.db.QueryContext(ctx, `
func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string, includeResolved bool) ([]Comment, error) {
query := `
SELECT c.id, c.document_id, c.version_hash, c.parent_id, c.author_id, COALESCE(u.display_name, u.email), c.content, c.anchor_hash, c.anchor_line, c.created_at, c.resolved_at, c.resolved_by
FROM comments c
JOIN users u ON u.id = c.author_id
WHERE c.document_id = ? AND c.anchor_hash = ? AND c.resolved_at IS NULL
ORDER BY c.created_at ASC
`, documentID, anchorHash)
WHERE c.document_id = ? AND c.anchor_hash = ?`
if !includeResolved {
query += ` AND c.resolved_at IS NULL`
}
query += ` ORDER BY c.created_at ASC`
rows, err := r.db.QueryContext(ctx, query, documentID, anchorHash)
if err != nil {
return nil, fmt.Errorf("list comments by anchor: %w", err)
}
@@ -95,13 +101,25 @@ func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string
return scanComments(rows)
}
func (r *Repository) ResolveComment(ctx context.Context, commentID string, userID string) error {
_, err := r.db.ExecContext(ctx, `
func (r *Repository) ResolveCommentThread(ctx context.Context, commentID string, userID string) error {
result, err := r.db.ExecContext(ctx, `
WITH RECURSIVE thread(id) AS (
SELECT id FROM comments WHERE id = ? AND parent_id IS NULL
UNION ALL
SELECT c.id FROM comments c JOIN thread t ON c.parent_id = t.id
)
UPDATE comments SET resolved_at = ?, resolved_by = ?
WHERE id = ?
`, formatTime(time.Now().UTC()), userID, commentID)
WHERE id IN (SELECT id FROM thread) AND resolved_at IS NULL
`, commentID, formatTime(time.Now().UTC()), userID)
if err != nil {
return fmt.Errorf("resolve comment: %w", err)
return fmt.Errorf("resolve comment thread: %w", err)
}
changed, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("resolve comment thread result: %w", err)
}
if changed == 0 {
return fmt.Errorf("comment thread not found or already resolved")
}
return nil
}
@@ -228,10 +246,12 @@ func (r *Repository) MarkAllNotificationsRead(ctx context.Context, userID string
// Watchers
func (r *Repository) AddWatcher(ctx context.Context, w Watcher) error {
if (w.DocumentID == nil) == (w.FolderPath == nil) {
return fmt.Errorf("watcher requires exactly one target")
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO watchers (user_id, document_id, folder_path, created_at)
INSERT OR IGNORE INTO watchers (user_id, document_id, folder_path, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, document_id, folder_path) DO NOTHING
`, w.UserID, w.DocumentID, w.FolderPath, formatTime(w.CreatedAt))
if err != nil {
return fmt.Errorf("add watcher: %w", err)
@@ -262,12 +282,12 @@ func (r *Repository) ListWatchersByDocument(ctx context.Context, documentID stri
return scanWatchers(rows)
}
func (r *Repository) ListWatchersByFolder(ctx context.Context, folderPath string) ([]Watcher, error) {
func (r *Repository) ListWatchersByFolder(ctx context.Context, documentPath string) ([]Watcher, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT user_id, document_id, folder_path, created_at
FROM watchers
WHERE folder_path = ? OR ? || '/' = substr(folder_path, 1, length(?) + 1)
`, folderPath, folderPath, folderPath)
WHERE folder_path = '' OR folder_path || '/' = substr(?, 1, length(folder_path) + 1)
`, documentPath)
if err != nil {
return nil, fmt.Errorf("list folder watchers: %w", err)
}
@@ -275,6 +295,24 @@ func (r *Repository) ListWatchersByFolder(ctx context.Context, folderPath string
return scanWatchers(rows)
}
func (r *Repository) ListWatchersForDocument(ctx context.Context, documentID string) ([]Watcher, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT DISTINCT w.user_id, w.document_id, w.folder_path, w.created_at
FROM watchers w
JOIN documents d ON d.id = ?
WHERE w.document_id = d.id
OR (w.folder_path IS NOT NULL AND (
w.folder_path = ''
OR w.folder_path || '/' = substr(d.path, 1, length(w.folder_path) + 1)
))
`, documentID)
if err != nil {
return nil, fmt.Errorf("list watchers for document: %w", err)
}
defer rows.Close()
return scanWatchers(rows)
}
func (r *Repository) IsWatching(ctx context.Context, userID string, documentID string) (bool, error) {
var count int
err := r.db.QueryRowContext(ctx, `

View File

@@ -0,0 +1,157 @@
package collaboration
import (
"context"
"database/sql"
"io"
"log/slog"
"path/filepath"
"testing"
"time"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database"
)
func newCollaborationTestRepository(t *testing.T) (*Repository, *sql.DB) {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "test.sqlite")})
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.ApplyMigrations(ctx, db.SQL()); err != nil {
t.Fatalf("apply migrations: %v", err)
}
now := formatTime(time.Now().UTC())
for _, user := range []struct{ id, email, name string }{
{"user:author", "author@example.com", "Author"},
{"user:watcher", "watcher@example.com", "Watcher"},
} {
if _, err := db.SQL().ExecContext(ctx, `
INSERT INTO users (id, email, display_name, role, created_at) VALUES (?, ?, ?, 'editor', ?)
`, user.id, user.email, user.name, now); err != nil {
t.Fatalf("insert user: %v", err)
}
}
for _, document := range []struct{ id, path, title string }{
{"doc:guide/setup", "guide/setup.md", "Setup"},
{"doc:other", "other.md", "Other"},
} {
if _, err := db.SQL().ExecContext(ctx, `
INSERT INTO documents (id, path, current_hash, title, tags, created_at, updated_at)
VALUES (?, ?, 'hash', ?, '[]', ?, ?)
`, document.id, document.path, document.title, now, now); err != nil {
t.Fatalf("insert document: %v", err)
}
}
return NewRepository(db.SQL()), db.SQL()
}
func TestWatcherTargetsAreIdempotentAndFolderMatchingUsesAncestors(t *testing.T) {
repo, db := newCollaborationTestRepository(t)
ctx := context.Background()
now := time.Now().UTC()
documentID := "doc:guide/setup"
folder := "guide"
for range 2 {
if err := repo.AddWatcher(ctx, Watcher{UserID: "user:watcher", DocumentID: &documentID, CreatedAt: now}); err != nil {
t.Fatalf("add document watcher: %v", err)
}
if err := repo.AddWatcher(ctx, Watcher{UserID: "user:watcher", FolderPath: &folder, CreatedAt: now}); err != nil {
t.Fatalf("add folder watcher: %v", err)
}
}
var count int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM watchers`).Scan(&count); err != nil {
t.Fatalf("count watchers: %v", err)
}
if count != 2 {
t.Fatalf("watcher count = %d, want 2", count)
}
tests := []struct {
path string
want int
}{
{"guide/setup.md", 1},
{"guide/deep/setup.md", 1},
{"guidebook/setup.md", 0},
{"other.md", 0},
}
for _, test := range tests {
watchers, err := repo.ListWatchersByFolder(ctx, test.path)
if err != nil {
t.Fatalf("ListWatchersByFolder(%q): %v", test.path, err)
}
if len(watchers) != test.want {
t.Errorf("ListWatchersByFolder(%q) = %d, want %d", test.path, len(watchers), test.want)
}
}
}
func TestCommentReplyValidationHistoryAndThreadResolution(t *testing.T) {
repo, _ := newCollaborationTestRepository(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
service := NewServiceWithOptions(repo, nil, logger, Options{})
ctx := context.Background()
principal := auth.Principal{UserID: "user:author", DisplayName: "Author"}
anchor := "anchor"
root, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Root", AnchorHash: &anchor,
})
if err != nil {
t.Fatalf("create root: %v", err)
}
reply, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Reply", ParentID: &root.ID,
})
if err != nil {
t.Fatalf("create reply: %v", err)
}
if reply.AnchorHash == nil || *reply.AnchorHash != anchor {
t.Fatalf("reply anchor = %#v, want inherited %q", reply.AnchorHash, anchor)
}
grandchild, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Grandchild", ParentID: &reply.ID,
})
if err != nil {
t.Fatalf("create grandchild: %v", err)
}
if _, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:other", VersionHash: "hash", Content: "Wrong document", ParentID: &root.ID,
}); err == nil {
t.Fatal("cross-document reply should fail")
}
if err := service.ResolveComment(ctx, principal, root.ID); err != nil {
t.Fatalf("resolve thread: %v", err)
}
active, err := service.ListCommentsByAnchor(ctx, "doc:guide/setup", anchor, false)
if err != nil {
t.Fatalf("list active: %v", err)
}
if len(active) != 0 {
t.Fatalf("active comments = %d, want 0", len(active))
}
history, err := service.ListCommentsByAnchor(ctx, "doc:guide/setup", anchor, true)
if err != nil {
t.Fatalf("list history: %v", err)
}
if len(history) != 3 {
t.Fatalf("history comments = %d, want 3", len(history))
}
for _, comment := range history {
if comment.ResolvedAt == nil {
t.Errorf("comment %s was not resolved", comment.ID)
}
}
if _, err := service.CreateComment(ctx, principal, CreateCommentInput{
DocumentID: "doc:guide/setup", VersionHash: "hash", Content: "Too late", ParentID: &grandchild.ID,
}); err == nil {
t.Fatal("reply to resolved thread should fail")
}
}

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

View File

@@ -4,10 +4,27 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"path"
"strings"
"time"
)
func NormalizeFolderPath(raw string) (string, error) {
raw = strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/"))
if raw == "" || raw == "/" {
return "", nil
}
clean := path.Clean(strings.Trim(raw, "/"))
if clean == "." {
return "", nil
}
if clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("folder path must remain inside the content root")
}
return clean, nil
}
func randomID(prefix string) string {
buf := make([]byte, 12)
if _, err := rand.Read(buf); err != nil {