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

@@ -73,6 +73,16 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
var emailQueue *email.Queue
emailRenderer := email.CollaborationRenderer{InstanceName: cfg.Email.InstanceName}
switch cfg.Email.ResolvedProvider() {
case "cloudflare":
cf := email.NewCloudflareSender(
cfg.Email.CloudflareAccountID,
cfg.Email.CloudflareAPIToken,
cfg.Email.From,
cfg.Email.CloudflareAPIURL,
logger,
)
emailSender = cf
authService.SetEmailSender(cf)
case "postmark":
pm := email.NewPostmarkSender(cfg.Email.PostmarkToken, cfg.Email.From, cfg.Email.PostmarkAPIURL, logger)
emailSender = pm
@@ -95,7 +105,22 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err
EmailQueue: emailQueue,
EmailRenderer: emailRenderer,
UserLookup: authRepo,
PublicOrigin: cfg.Auth.PublicOrigin,
CanReadDocument: func(ctx context.Context, userID, documentID string) (bool, error) {
document, err := repo.GetDocumentByID(ctx, documentID)
if err != nil {
return false, err
}
principal, err := authService.PrincipalForUser(ctx, userID)
if err != nil {
return false, err
}
permission, err := authService.EffectiveResourcePermission(ctx, principal, auth.ResourceDocument, document.Path, document.Tags)
if err != nil {
return false, err
}
return auth.PermissionAllows(permission, auth.PermissionRead), nil
},
PublicOrigin: cfg.Auth.PublicOrigin,
})
service.OnChange(func(change docs.DocumentChange) {

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 {

View File

@@ -39,15 +39,18 @@ type AuthConfig struct {
type EmailConfig struct {
// Provider selects the sender implementation. Valid values: "noop",
// "smtp", "postmark". When empty, the loader infers from the other
// fields: PostmarkToken wins, then SMTPHost, then NoOp.
Provider string `json:"provider"`
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
PostmarkToken string `json:"postmarkToken"`
PostmarkAPIURL string `json:"postmarkApiUrl"`
From string `json:"from"`
InstanceName string `json:"instanceName"`
// "smtp", "postmark", or "cloudflare". When empty, the loader infers
// from credentials, then SMTPHost, then NoOp.
Provider string `json:"provider"`
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
PostmarkToken string `json:"postmarkToken"`
PostmarkAPIURL string `json:"postmarkApiUrl"`
CloudflareAccountID string `json:"cloudflareAccountId"`
CloudflareAPIToken string `json:"cloudflareApiToken"`
CloudflareAPIURL string `json:"cloudflareApiUrl"`
From string `json:"from"`
InstanceName string `json:"instanceName"`
}
// ResolvedProvider returns the concrete provider choice after applying the
@@ -56,6 +59,9 @@ func (e EmailConfig) ResolvedProvider() string {
if e.Provider != "" {
return e.Provider
}
if e.CloudflareAPIToken != "" || e.CloudflareAccountID != "" {
return "cloudflare"
}
if e.PostmarkToken != "" {
return "postmark"
}
@@ -112,6 +118,9 @@ func Load() (Config, error) {
overrideString(&cfg.Email.SMTPHost, "CAIRNQUIRE_EMAIL_SMTP_HOST")
overrideString(&cfg.Email.PostmarkToken, "CAIRNQUIRE_EMAIL_POSTMARK_TOKEN")
overrideString(&cfg.Email.PostmarkAPIURL, "CAIRNQUIRE_EMAIL_POSTMARK_API_URL")
overrideString(&cfg.Email.CloudflareAccountID, "CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID")
overrideString(&cfg.Email.CloudflareAPIToken, "CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN")
overrideString(&cfg.Email.CloudflareAPIURL, "CAIRNQUIRE_EMAIL_CLOUDFLARE_API_URL")
overrideString(&cfg.Email.Provider, "CAIRNQUIRE_EMAIL_PROVIDER")
overrideString(&cfg.Email.InstanceName, "CAIRNQUIRE_INSTANCE_NAME")
if port := os.Getenv("CAIRNQUIRE_EMAIL_SMTP_PORT"); port != "" {

View File

@@ -0,0 +1,46 @@
package config
import "testing"
func TestEmailConfigResolvedProvider(t *testing.T) {
tests := []struct {
name string
cfg EmailConfig
want string
}{
{"explicit", EmailConfig{Provider: "noop", CloudflareAPIToken: "token"}, "noop"},
{"cloudflare", EmailConfig{CloudflareAccountID: "account"}, "cloudflare"},
{"postmark", EmailConfig{PostmarkToken: "token"}, "postmark"},
{"smtp", EmailConfig{SMTPHost: "localhost"}, "smtp"},
{"noop", EmailConfig{}, "noop"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := test.cfg.ResolvedProvider(); got != test.want {
t.Fatalf("ResolvedProvider() = %q, want %q", got, test.want)
}
})
}
}
func TestLoadCloudflareEmailEnvironment(t *testing.T) {
t.Setenv("CAIRNQUIRE_EMAIL_PROVIDER", "cloudflare")
t.Setenv("CAIRNQUIRE_EMAIL_CLOUDFLARE_ACCOUNT_ID", "account")
t.Setenv("CAIRNQUIRE_EMAIL_CLOUDFLARE_API_TOKEN", "token")
t.Setenv("CAIRNQUIRE_EMAIL_CLOUDFLARE_API_URL", "https://api.example.test")
t.Setenv("CAIRNQUIRE_EMAIL_FROM", "notifications@example.com")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Email.ResolvedProvider() != "cloudflare" {
t.Fatalf("provider = %q", cfg.Email.ResolvedProvider())
}
if cfg.Email.CloudflareAccountID != "account" || cfg.Email.CloudflareAPIToken != "token" {
t.Fatalf("cloudflare config = %#v", cfg.Email)
}
if cfg.Email.CloudflareAPIURL != "https://api.example.test" || cfg.Email.From != "notifications@example.com" {
t.Fatalf("cloudflare endpoint/from = %#v", cfg.Email)
}
}

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_watchers_user_folder_unique;
DROP INDEX IF EXISTS idx_watchers_user_document_unique;

View File

@@ -0,0 +1,14 @@
DELETE FROM watchers
WHERE rowid NOT IN (
SELECT MIN(rowid)
FROM watchers
GROUP BY user_id, document_id, folder_path
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_watchers_user_document_unique
ON watchers(user_id, document_id)
WHERE document_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_watchers_user_folder_unique
ON watchers(user_id, folder_path)
WHERE folder_path IS NOT NULL;

View File

@@ -0,0 +1,160 @@
package email
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
const (
defaultCloudflareAPIURL = "https://api.cloudflare.com/client/v4"
cloudflareTimeout = 15 * time.Second
)
// CloudflareSender sends transactional email through Cloudflare Email
// Service's REST API. It is intended for deployments outside Workers.
type CloudflareSender struct {
accountID string
apiToken string
apiURL string
from string
client *http.Client
logger *slog.Logger
}
func NewCloudflareSender(accountID, apiToken, from, apiURL string, logger *slog.Logger) *CloudflareSender {
if apiURL == "" {
apiURL = defaultCloudflareAPIURL
}
if logger == nil {
logger = slog.Default()
}
return &CloudflareSender{
accountID: accountID,
apiToken: apiToken,
apiURL: strings.TrimRight(apiURL, "/"),
from: from,
client: &http.Client{
Timeout: cloudflareTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
},
logger: logger,
}
}
type cloudflareRequest struct {
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
Text string `json:"text"`
}
type cloudflareResponse struct {
Success bool `json:"success"`
Errors []struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"errors"`
Result *struct {
Delivered []string `json:"delivered"`
MessageID string `json:"message_id"`
PermanentBounces []string `json:"permanent_bounces"`
Queued []string `json:"queued"`
} `json:"result"`
}
func (c *CloudflareSender) Send(ctx context.Context, to []string, subject, body string) error {
if c.accountID == "" {
return &PermanentError{Cause: fmt.Errorf("cloudflare account id not configured")}
}
if c.apiToken == "" {
return &PermanentError{Cause: fmt.Errorf("cloudflare API token not configured")}
}
if c.from == "" {
return &PermanentError{Cause: fmt.Errorf("cloudflare from address not configured")}
}
if len(to) == 0 {
return &PermanentError{Cause: fmt.Errorf("cloudflare send requires at least one recipient")}
}
payload, err := json.Marshal(cloudflareRequest{
From: c.from,
To: to,
Subject: subject,
Text: body,
})
if err != nil {
return fmt.Errorf("marshal cloudflare email request: %w", err)
}
endpoint := fmt.Sprintf("%s/accounts/%s/email/sending/send", c.apiURL, url.PathEscape(c.accountID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("build cloudflare email request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return &TemporaryError{Cause: fmt.Errorf("cloudflare email request: %w", err)}
}
defer resp.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<14))
if readErr != nil {
return &TemporaryError{Cause: fmt.Errorf("read cloudflare email response: %w", readErr)}
}
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError {
return &TemporaryError{Cause: fmt.Errorf("cloudflare email server error: status=%d body=%s", resp.StatusCode, truncate(string(responseBody)))}
}
if resp.StatusCode != http.StatusOK {
return &PermanentError{Cause: fmt.Errorf("cloudflare email rejected: status=%d body=%s", resp.StatusCode, truncate(string(responseBody)))}
}
var parsed cloudflareResponse
if err := json.Unmarshal(responseBody, &parsed); err != nil {
return fmt.Errorf("decode cloudflare email response: %w", err)
}
if !parsed.Success {
return &PermanentError{Cause: fmt.Errorf("cloudflare email rejected: %s", cloudflareErrors(parsed.Errors))}
}
if parsed.Result == nil {
return fmt.Errorf("cloudflare email response missing result")
}
if len(parsed.Result.PermanentBounces) > 0 {
return &PermanentError{Cause: fmt.Errorf("cloudflare email permanently bounced for %d recipient(s)", len(parsed.Result.PermanentBounces))}
}
c.logger.Info("cloudflare email accepted",
"message_id", parsed.Result.MessageID,
"delivered", len(parsed.Result.Delivered),
"queued", len(parsed.Result.Queued),
"subject", subject,
)
return nil
}
func cloudflareErrors(errors []struct {
Code int `json:"code"`
Message string `json:"message"`
}) string {
if len(errors) == 0 {
return "unknown API error"
}
parts := make([]string, 0, len(errors))
for _, apiErr := range errors {
parts = append(parts, fmt.Sprintf("code=%d: %s", apiErr.Code, apiErr.Message))
}
return strings.Join(parts, "; ")
}

View File

@@ -0,0 +1,128 @@
package email
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestCloudflareSenderSend(t *testing.T) {
var got cloudflareRequest
var gotAuthorization string
var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuthorization = r.Header.Get("Authorization")
gotPath = r.URL.Path
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &got)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"errors":[],"messages":[],"result":{"delivered":["alice@example.com"],"message_id":"message-1","permanent_bounces":[],"queued":[]}}`))
}))
defer server.Close()
sender := NewCloudflareSender("account-1", "token-1", "notifications@example.com", server.URL, testLogger(t))
if err := sender.Send(context.Background(), []string{"alice@example.com"}, "Hello", "World"); err != nil {
t.Fatalf("Send: %v", err)
}
if gotAuthorization != "Bearer token-1" {
t.Errorf("authorization = %q", gotAuthorization)
}
if gotPath != "/accounts/account-1/email/sending/send" {
t.Errorf("path = %q", gotPath)
}
if got.From != "notifications@example.com" || got.Subject != "Hello" || got.Text != "World" {
t.Errorf("request = %#v", got)
}
if len(got.To) != 1 || got.To[0] != "alice@example.com" {
t.Errorf("to = %#v", got.To)
}
}
func TestCloudflareSenderRetryableResponses(t *testing.T) {
for _, status := range []int{http.StatusTooManyRequests, http.StatusInternalServerError} {
t.Run(http.StatusText(status), func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"success":false}`))
}))
defer server.Close()
sender := NewCloudflareSender("account", "token", "from@example.com", server.URL, testLogger(t))
err := sender.Send(context.Background(), []string{"to@example.com"}, "subject", "body")
var temporary *TemporaryError
if !errors.As(err, &temporary) {
t.Fatalf("error = %T %v, want TemporaryError", err, err)
}
})
}
}
func TestCloudflareSenderPermanentRejection(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"success":false,"errors":[{"code":1000,"message":"Sender domain not verified"}]}`))
}))
defer server.Close()
sender := NewCloudflareSender("account", "token", "from@example.com", server.URL, testLogger(t))
err := sender.Send(context.Background(), []string{"to@example.com"}, "subject", "body")
if err == nil || !strings.Contains(err.Error(), "400") {
t.Fatalf("error = %v, want permanent 400 rejection", err)
}
var temporary *TemporaryError
if errors.As(err, &temporary) {
t.Fatalf("error = %T, should not be retryable", err)
}
}
func TestCloudflareSenderAPIErrorAndBounce(t *testing.T) {
tests := []struct {
name string
body string
want string
}{
{"api error", `{"success":false,"errors":[{"code":1000,"message":"Sender domain not verified"}],"result":null}`, "Sender domain not verified"},
{"bounce", `{"success":true,"errors":[],"result":{"delivered":[],"message_id":"message-1","permanent_bounces":["to@example.com"],"queued":[]}}`, "permanently bounced"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(test.body))
}))
defer server.Close()
sender := NewCloudflareSender("account", "token", "from@example.com", server.URL, testLogger(t))
err := sender.Send(context.Background(), []string{"to@example.com"}, "subject", "body")
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want substring %q", err, test.want)
}
})
}
}
func TestCloudflareSenderValidation(t *testing.T) {
tests := []struct {
name string
accountID string
token string
from string
to []string
}{
{"account", "", "token", "from@example.com", []string{"to@example.com"}},
{"token", "account", "", "from@example.com", []string{"to@example.com"}},
{"from", "account", "token", "", []string{"to@example.com"}},
{"recipient", "account", "token", "from@example.com", nil},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
sender := NewCloudflareSender(test.accountID, test.token, test.from, "", testLogger(t))
if err := sender.Send(context.Background(), test.to, "subject", "body"); err == nil {
t.Fatal("expected validation error")
}
})
}
}

View File

@@ -36,16 +36,21 @@ func NewPostmarkSender(serverToken, from, apiURL string, logger *slog.Logger) *P
serverToken: serverToken,
apiURL: strings.TrimRight(apiURL, "/"),
from: from,
client: &http.Client{Timeout: postmarkTimeout},
logger: logger,
client: &http.Client{
Timeout: postmarkTimeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
},
logger: logger,
}
}
type postmarkRequest struct {
From string `json:"From"`
To string `json:"To"`
Subject string `json:"Subject"`
TextBody string `json:"TextBody"`
From string `json:"From"`
To string `json:"To"`
Subject string `json:"Subject"`
TextBody string `json:"TextBody"`
Headers []postmarkHeader `json:"Headers,omitempty"`
}
@@ -66,13 +71,13 @@ type postmarkResponse struct {
// send if they need different reply-to addresses.
func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body string) error {
if p.serverToken == "" {
return fmt.Errorf("postmark server token not configured")
return &PermanentError{Cause: fmt.Errorf("postmark server token not configured")}
}
if p.from == "" {
return fmt.Errorf("postmark from address not configured")
return &PermanentError{Cause: fmt.Errorf("postmark from address not configured")}
}
if len(to) == 0 {
return fmt.Errorf("postmark send requires at least one recipient")
return &PermanentError{Cause: fmt.Errorf("postmark send requires at least one recipient")}
}
payload := postmarkRequest{
@@ -96,7 +101,7 @@ func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body st
resp, err := p.client.Do(req)
if err != nil {
return fmt.Errorf("postmark request: %w", err)
return &TemporaryError{Cause: fmt.Errorf("postmark request: %w", err)}
}
defer resp.Body.Close()
@@ -107,7 +112,7 @@ func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body st
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("postmark rejected: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))
return &PermanentError{Cause: fmt.Errorf("postmark rejected: status=%d body=%s", resp.StatusCode, truncate(string(respBytes)))}
}
var parsed postmarkResponse
@@ -117,7 +122,7 @@ func (p *PostmarkSender) Send(ctx context.Context, to []string, subject, body st
if parsed.ErrorCode != 0 {
// Postmark distinguishes "inactive recipient" (406) and other errors. Treat
// 406 as non-retryable; others bubble up as generic failures.
return fmt.Errorf("postmark error code=%d: %s", parsed.ErrorCode, parsed.Message)
return &PermanentError{Cause: fmt.Errorf("postmark error code=%d: %s", parsed.ErrorCode, parsed.Message)}
}
p.logger.Info("postmark email sent", "to", parsed.To, "subject", subject)
@@ -139,3 +144,11 @@ type TemporaryError struct {
func (t *TemporaryError) Error() string { return t.Cause.Error() }
func (t *TemporaryError) Unwrap() error { return t.Cause }
// PermanentError marks a provider rejection that cannot succeed on retry.
type PermanentError struct {
Cause error
}
func (p *PermanentError) Error() string { return p.Cause.Error() }
func (p *PermanentError) Unwrap() error { return p.Cause }

View File

@@ -3,6 +3,7 @@ package email
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"math"
@@ -211,17 +212,28 @@ func (q *Queue) deliver(ctx context.Context, row QueueRow) error {
}
// Failure: retry or give up.
if row.Attempts >= row.MaxAttempts {
var permanent *PermanentError
if errors.As(err, &permanent) {
if _, ferr := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ?
`, truncateErr(err.Error()), row.ID); ferr != nil {
return fmt.Errorf("mark permanent failure: %w", ferr)
}
q.logger.Warn("email permanently rejected", "id", row.ID, "attempts", row.Attempts+1, "error", err)
return nil
}
currentAttempt := row.Attempts + 1
if currentAttempt >= row.MaxAttempts {
if _, ferr := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'failed', last_error = ? WHERE id = ?
`, truncateErr(err.Error()), row.ID); ferr != nil {
return fmt.Errorf("mark failed: %w", ferr)
}
q.logger.Warn("email permanently failed", "id", row.ID, "attempts", row.Attempts, "error", err)
q.logger.Warn("email permanently failed", "id", row.ID, "attempts", currentAttempt, "error", err)
return nil
}
backoff := q.backoff(row.Attempts)
backoff := q.backoff(currentAttempt)
next := time.Now().UTC().Add(backoff)
if _, err := q.db.ExecContext(ctx, `
UPDATE email_queue SET status = 'pending', next_attempt_at = ?, last_error = ? WHERE id = ?
@@ -229,9 +241,9 @@ func (q *Queue) deliver(ctx context.Context, row QueueRow) error {
return fmt.Errorf("schedule retry: %w", err)
}
// TemporaryError (Postmark 5xx) is retried with the same backoff as
// other errors; the max-attempts cap still bounds total work.
q.logger.Info("email retry scheduled", "id", row.ID, "attempt", row.Attempts, "backoff", backoff, "error", err)
// Temporary provider failures and unclassified sender failures are retried;
// PermanentError is handled above without wasting additional attempts.
q.logger.Info("email retry scheduled", "id", row.ID, "attempt", currentAttempt, "backoff", backoff, "error", err)
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
@@ -152,9 +153,9 @@ func TestQueue_RetryThenSucceed(t *testing.T) {
go q.Run(ctx)
if err := waitFor(ctx, func() bool {
return sender.successCount >= 1
return sender.successCount.Load() >= 1
}); err != nil {
t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount, sender.attempts, err)
t.Fatalf("email never succeeded: successCount=%d attempts=%d: %v", sender.successCount.Load(), sender.attempts.Load(), err)
}
// Verify the row is marked sent.
@@ -196,6 +197,32 @@ func TestQueue_PermanentFailure(t *testing.T) {
}
}
func TestQueue_ProviderRejectionFailsImmediately(t *testing.T) {
db := newTestDB(t)
sender := &recordingSender{err: &PermanentError{Cause: errors.New("sender domain not verified")}}
q := NewQueue(db, sender, testLogger(t))
id, err := q.Enqueue(context.Background(), "notif:test-1", "alice@example.com", "Fail", "Body")
if err != nil {
t.Fatalf("Enqueue: %v", err)
}
rows, err := q.claimBatch(context.Background(), 1)
if err != nil || len(rows) != 1 {
t.Fatalf("claimBatch = %#v, %v", rows, err)
}
if err := q.deliver(context.Background(), rows[0]); err != nil {
t.Fatalf("deliver: %v", err)
}
var status string
var attempts int
if err := db.QueryRow(`SELECT status, attempts FROM email_queue WHERE id = ?`, id).Scan(&status, &attempts); err != nil {
t.Fatalf("query row: %v", err)
}
if status != "failed" || attempts != 1 {
t.Fatalf("status=%q attempts=%d, want failed after one attempt", status, attempts)
}
}
func TestQueue_NilSenderLeavesPending(t *testing.T) {
db := newTestDB(t)
q := NewQueue(db, nil, testLogger(t))
@@ -218,17 +245,17 @@ func TestQueue_NilSenderLeavesPending(t *testing.T) {
// flakySender fails the first N attempts then succeeds.
type flakySender struct {
failTimes int
attempts int
successCount int
failTimes int32
attempts atomic.Int32
successCount atomic.Int32
}
func (f *flakySender) Send(ctx context.Context, to []string, subject, body string) error {
f.attempts++
if f.attempts <= f.failTimes {
attempt := f.attempts.Add(1)
if attempt <= f.failTimes {
return errors.New("transient failure")
}
f.successCount++
f.successCount.Add(1)
return nil
}

View File

@@ -9,16 +9,16 @@ import (
// TemplateData carries the context required to render any notification email.
// Fields are optional per template; callers should populate what they have.
type TemplateData struct {
ActorName string
ActorEmail string
DocumentPath string
ActorName string
ActorEmail string
DocumentPath string
DocumentTitle string
CommentBody string
MentionText string
ConflictDesc string
ViewURL string
UnsubURL string
InstanceName string
CommentBody string
MentionText string
ConflictDesc string
ViewURL string
UnsubURL string
InstanceName string
}
// Render returns a plain-text subject and body for the given notification
@@ -101,7 +101,7 @@ func renderComment(data TemplateData) (string, string, error) {
} else {
b.WriteString("> (no body)\n")
}
b.WriteString("\nReply to this email to respond.\n")
b.WriteString("\nOpen the document to reply.\n")
b.WriteString(footer(data))
return subject, b.String(), nil
}

View File

@@ -7,12 +7,12 @@ import (
func TestRender_FileChanged(t *testing.T) {
subject, body, err := Render("file_changed", TemplateData{
ActorName: "Alice",
DocumentPath: "docs/api.md",
ActorName: "Alice",
DocumentPath: "docs/api.md",
DocumentTitle: "API Reference",
ViewURL: "https://example.com/docs/api.md",
UnsubURL: "https://example.com/unsub/123",
InstanceName: "Cairnquire",
ViewURL: "https://example.com/docs/api.md",
UnsubURL: "https://example.com/unsub/123",
InstanceName: "Cairnquire",
})
if err != nil {
t.Fatalf("Render: %v", err)
@@ -39,10 +39,10 @@ func TestRender_FileChanged(t *testing.T) {
func TestRender_Comment(t *testing.T) {
subject, body, err := Render("comment", TemplateData{
ActorName: "Bob",
ActorName: "Bob",
DocumentTitle: "Getting Started",
CommentBody: "This step is unclear.\nCan we add an example?",
ViewURL: "https://example.com/docs/getting-started",
CommentBody: "This step is unclear.\nCan we add an example?",
ViewURL: "https://example.com/docs/getting-started",
})
if err != nil {
t.Fatalf("Render: %v", err)
@@ -56,16 +56,16 @@ func TestRender_Comment(t *testing.T) {
if !strings.Contains(body, "> Can we add an example?") {
t.Errorf("body should quote second line: %q", body)
}
if !strings.Contains(body, "Reply to this email") {
t.Errorf("body should mention reply-to-respond: %q", body)
if !strings.Contains(body, "Open the document to reply") {
t.Errorf("body should link users to the supported reply flow: %q", body)
}
}
func TestRender_Mention(t *testing.T) {
subject, body, err := Render("mention", TemplateData{
ActorName: "Carol",
ActorName: "Carol",
DocumentTitle: "Spec",
MentionText: "@dan please review",
MentionText: "@dan please review",
})
if err != nil {
t.Fatalf("Render: %v", err)

View File

@@ -16,6 +16,7 @@ import (
"testing"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/collaboration"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/docs"
@@ -99,6 +100,13 @@ func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMo
}
syncRepo := sync.NewRepository(db.SQL())
hub := realtime.NewHub(logger)
collabService := collaboration.NewServiceWithOptions(
collaboration.NewRepository(db.SQL()),
hub,
logger,
collaboration.Options{UserLookup: auth.NewRepository(db.SQL()), PublicOrigin: "http://localhost:8080"},
)
handler, err := New(Dependencies{
Config: config.Config{
Content: config.ContentConfig{
@@ -108,14 +116,15 @@ func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMo
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
DevMode: devMode,
},
Logger: logger,
Documents: docService,
Repository: docRepo,
ContentStore: contentStore,
Hub: realtime.NewHub(logger),
SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
SyncRepo: syncRepo,
Auth: authService,
Logger: logger,
Documents: docService,
Repository: docRepo,
ContentStore: contentStore,
Hub: hub,
SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger),
SyncRepo: syncRepo,
Auth: authService,
Collaboration: collabService,
})
if err != nil {
t.Fatalf("create http server: %v", err)

View File

@@ -3,6 +3,8 @@ package httpserver
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/tim/cairnquire/apps/server/internal/collaboration"
@@ -32,11 +34,12 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
}
anchorHash := r.URL.Query().Get("anchor_hash")
includeResolved := r.URL.Query().Get("include_resolved") == "true"
var comments []collaboration.Comment
if anchorHash != "" {
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash)
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash, includeResolved)
} else {
comments, err = s.collaboration.ListComments(r.Context(), documentID)
comments, err = s.collaboration.ListComments(r.Context(), documentID, includeResolved)
}
if err != nil {
s.logger.Warn("list comments", "error", err)
@@ -128,8 +131,13 @@ func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request)
unreadOnly := r.URL.Query().Get("unread") == "true"
limit := 50
if r.URL.Query().Get("limit") != "" {
// Parse limit if needed, but for now just use default
if rawLimit := r.URL.Query().Get("limit"); rawLimit != "" {
parsed, err := strconv.Atoi(rawLimit)
if err != nil || parsed < 1 || parsed > 100 {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "limit must be between 1 and 100"})
return
}
limit = parsed
}
notifications, err := s.collaboration.ListNotifications(r.Context(), principal, unreadOnly, limit)
@@ -204,6 +212,21 @@ func (s *Server) handleWatchDocument(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
req.DocumentID = strings.TrimSpace(req.DocumentID)
document, err := s.repository.GetDocumentByID(r.Context(), req.DocumentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canRead, err := s.canReadDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canRead {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
if err := s.collaboration.WatchDocument(r.Context(), principal, req.DocumentID); err != nil {
s.logger.Warn("watch document", "error", err)
@@ -227,6 +250,7 @@ func (s *Server) handleUnwatchDocument(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
req.DocumentID = strings.TrimSpace(req.DocumentID)
if err := s.collaboration.UnwatchDocument(r.Context(), principal, req.DocumentID); err != nil {
s.logger.Warn("unwatch document", "error", err)
@@ -250,8 +274,17 @@ func (s *Server) handleWatchFolder(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
normalized, err := collaboration.NormalizeFolderPath(req.FolderPath)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if !s.canReadFolder(r, normalized) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "folder not found"})
return
}
if err := s.collaboration.WatchFolder(r.Context(), principal, req.FolderPath); err != nil {
if err := s.collaboration.WatchFolder(r.Context(), principal, normalized); err != nil {
s.logger.Warn("watch folder", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
@@ -273,8 +306,13 @@ func (s *Server) handleUnwatchFolder(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
normalized, err := collaboration.NormalizeFolderPath(req.FolderPath)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := s.collaboration.UnwatchFolder(r.Context(), principal, req.FolderPath); err != nil {
if err := s.collaboration.UnwatchFolder(r.Context(), principal, normalized); err != nil {
s.logger.Warn("unwatch folder", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
@@ -290,6 +328,20 @@ func (s *Server) handleIsWatching(w http.ResponseWriter, r *http.Request) {
}
documentID := r.URL.Query().Get("document_id")
document, err := s.repository.GetDocumentByID(r.Context(), documentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canRead, err := s.canReadDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canRead {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
watching, err := s.collaboration.IsWatching(r.Context(), principal, documentID)
if err != nil {
s.logger.Warn("is watching", "error", err)

View File

@@ -0,0 +1,141 @@
package httpserver
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/collaboration"
)
func loginTestUser(t *testing.T, server *apiTestServer) *http.Cookie {
t.Helper()
login := httptest.NewRecorder()
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
"email": "editor@example.com", "password": "very secure password",
}))
if login.Code != http.StatusOK {
t.Fatalf("login response = %d %q", login.Code, login.Body.String())
}
return cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
}
func authenticatedJSONRequest(t *testing.T, method, target string, body any, session *http.Cookie) *http.Request {
t.Helper()
request := jsonRequest(method, target, body)
request.AddCookie(session)
return request
}
func TestDocumentLoadsCollaborationControlsAndWatchFlow(t *testing.T) {
server := newAPITestServer(t)
session := loginTestUser(t, server)
pageRequest := httptest.NewRequest(http.MethodGet, "/docs/hello", nil)
pageRequest.AddCookie(session)
page := httptest.NewRecorder()
server.handler.ServeHTTP(page, pageRequest)
if page.Code != http.StatusOK {
t.Fatalf("document response = %d %q", page.Code, page.Body.String())
}
for _, expected := range [][]byte{
[]byte(`/static/notification-bell.js`),
[]byte(`data-document-id="doc:hello"`),
[]byte(`data-document-watch`),
[]byte(`data-comments-history`),
} {
if !bytes.Contains(page.Body.Bytes(), expected) {
t.Errorf("document page missing %q", expected)
}
}
statusRequest := httptest.NewRequest(http.MethodGet, "/api/watch/status?document_id=doc%3Ahello", nil)
statusRequest.AddCookie(session)
status := httptest.NewRecorder()
server.handler.ServeHTTP(status, statusRequest)
if status.Code != http.StatusOK || !bytes.Contains(status.Body.Bytes(), []byte(`"watching":false`)) {
t.Fatalf("initial watch status = %d %q", status.Code, status.Body.String())
}
watch := httptest.NewRecorder()
server.handler.ServeHTTP(watch, authenticatedJSONRequest(t, http.MethodPost, "/api/watch/document", map[string]string{"documentId": "doc:hello"}, session))
if watch.Code != http.StatusOK {
t.Fatalf("watch response = %d %q", watch.Code, watch.Body.String())
}
status = httptest.NewRecorder()
server.handler.ServeHTTP(status, statusRequest.Clone(statusRequest.Context()))
if status.Code != http.StatusOK || !bytes.Contains(status.Body.Bytes(), []byte(`"watching":true`)) {
t.Fatalf("watched status = %d %q", status.Code, status.Body.String())
}
unwatch := httptest.NewRecorder()
server.handler.ServeHTTP(unwatch, authenticatedJSONRequest(t, http.MethodDelete, "/api/watch/document", map[string]string{"documentId": "doc:hello"}, session))
if unwatch.Code != http.StatusOK {
t.Fatalf("unwatch response = %d %q", unwatch.Code, unwatch.Body.String())
}
}
func TestCommentReplyResolveAndHistoryHTTPFlow(t *testing.T) {
server := newAPITestServer(t)
session := loginTestUser(t, server)
anchor := "section-anchor"
create := func(content string, parentID *string) collaboration.Comment {
t.Helper()
response := httptest.NewRecorder()
server.handler.ServeHTTP(response, authenticatedJSONRequest(t, http.MethodPost, "/api/comments", collaboration.CreateCommentInput{
DocumentID: "doc:hello", VersionHash: "hash", ParentID: parentID, Content: content, AnchorHash: &anchor,
}, session))
if response.Code != http.StatusCreated {
t.Fatalf("create comment response = %d %q", response.Code, response.Body.String())
}
var comment collaboration.Comment
if err := json.NewDecoder(response.Body).Decode(&comment); err != nil {
t.Fatalf("decode comment: %v", err)
}
return comment
}
root := create("Root", nil)
reply := create("Reply", &root.ID)
_ = create("Grandchild", &reply.ID)
resolve := httptest.NewRecorder()
server.handler.ServeHTTP(resolve, authenticatedJSONRequest(t, http.MethodPost, "/api/comments/"+root.ID+"/resolve", map[string]any{}, session))
if resolve.Code != http.StatusOK {
t.Fatalf("resolve response = %d %q", resolve.Code, resolve.Body.String())
}
list := func(includeResolved bool) []collaboration.Comment {
t.Helper()
target := "/api/comments?document_id=doc%3Ahello&anchor_hash=" + anchor
if includeResolved {
target += "&include_resolved=true"
}
request := httptest.NewRequest(http.MethodGet, target, nil)
request.AddCookie(session)
response := httptest.NewRecorder()
server.handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("list comments response = %d %q", response.Code, response.Body.String())
}
var body struct {
Comments []collaboration.Comment `json:"comments"`
}
if err := json.NewDecoder(response.Body).Decode(&body); err != nil {
t.Fatalf("decode comments: %v", err)
}
return body.Comments
}
if active := list(false); len(active) != 0 {
t.Fatalf("active comments = %d, want 0", len(active))
}
if history := list(true); len(history) != 3 {
t.Fatalf("history comments = %d, want 3", len(history))
}
}

View File

@@ -39,6 +39,7 @@ type indexData struct {
type documentData struct {
Browser browserData
ID string
Title string
Path string
Tags []string
@@ -375,6 +376,7 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
BodyTemplate: "document_content",
Data: documentData{
Browser: browser,
ID: record.ID,
Title: page.Title,
Path: page.Path,
Tags: page.Tags,

View File

@@ -207,7 +207,10 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
case strings.HasPrefix(path, "/api/content/"):
return auth.ScopeSyncRead, true
case strings.HasPrefix(path, "/api/comments"):
return auth.ScopeDocsRead, true
if method == http.MethodGet {
return auth.ScopeDocsRead, true
}
return auth.ScopeDocsWrite, true
case strings.HasPrefix(path, "/api/notifications"):
return auth.ScopeDocsRead, true
case strings.HasPrefix(path, "/api/watch"):

View File

@@ -1,23 +1,29 @@
(function () {
'use strict';
const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath;
const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash;
const canComment = document.querySelector('[data-document-path]')?.dataset.canComment === 'true';
const article = document.querySelector('[data-document-path]');
const workspaceShell = article?.closest('.workspace-shell--document');
const article = document.querySelector('[data-document-id]');
if (!article) return;
const documentId = article.dataset.documentId;
const documentHash = article.dataset.documentHash;
const canComment = article.dataset.canComment === 'true';
const workspaceShell = article.closest('.workspace-shell--document');
const commentsAsideList = document.querySelector('[data-comments-aside-list]');
const commentsAsideListMobile = document.querySelector('[data-comments-aside-list-mobile]');
const stripTrack = document.querySelector('[data-comments-strip-track]');
const commentsAside = document.querySelector('[data-comments-aside]');
const commentsSheetToggle = document.querySelector('[data-toggle-comments]');
const commentsSheetClose = document.querySelector('[data-close-comments]');
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
const commentToggle = document.querySelector('[data-comment-toggle]');
const commentVisibilityKey = documentId ? `cairnquire:document-comments:${documentId}` : null;
const watchButton = document.querySelector('[data-document-watch]');
const historyButtons = document.querySelectorAll('[data-comments-history]');
const commentVisibilityKey = `cairnquire:document-comments:${documentId}`;
const commentsByAnchor = new Map();
const elementsByAnchor = new Map();
let includeResolved = false;
let watching = false;
function readCommentVisibility() {
if (!commentVisibilityKey) return false;
try {
return window.localStorage.getItem(commentVisibilityKey) === 'hidden';
} catch (err) {
@@ -25,29 +31,17 @@
}
}
if (!documentId) return;
function syncVisibility() {
const hidden = readCommentVisibility();
if (article) article.classList.toggle('comments-hidden', hidden);
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
}
syncVisibility();
function commentsHidden() {
return Boolean(workspaceShell?.classList.contains('comments-visible')) === false;
return !workspaceShell?.classList.contains('comments-visible');
}
function setCommentsHidden(hidden) {
if (article) article.classList.toggle('comments-hidden', hidden);
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
if (commentVisibilityKey) {
try {
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
} catch (err) {
// Ignore persistence failures
}
article.classList.toggle('comments-hidden', hidden);
workspaceShell?.classList.toggle('comments-visible', !hidden);
try {
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
} catch (err) {
// Storage can be unavailable in private browsing contexts.
}
if (commentToggle) {
commentToggle.setAttribute('aria-pressed', hidden ? 'false' : 'true');
@@ -60,173 +54,218 @@
}
}
if (commentToggle) {
commentToggle.setAttribute('aria-pressed', commentsHidden() ? 'false' : 'true');
commentToggle.addEventListener('click', () => {
setCommentsHidden(!commentsHidden());
});
}
setCommentsHidden(readCommentVisibility());
commentToggle?.addEventListener('click', () => setCommentsHidden(!commentsHidden()));
function hashParagraph(el) {
const text = (el.textContent || '').trim();
if (!text) return null;
let hash = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
hash = ((hash << 5) - hash) + text.charCodeAt(i);
hash &= hash;
}
return 'h' + Math.abs(hash).toString(36);
}
function addCommentButton(el, hash) {
const btn = document.createElement('button');
btn.className = 'comment-anchor-btn';
btn.type = 'button';
btn.title = 'Add comment';
btn.setAttribute('aria-label', 'Add comment');
btn.innerHTML = `
function addCommentButton(el, anchorHash) {
const button = document.createElement('button');
button.className = 'comment-anchor-btn';
button.type = 'button';
button.title = 'Add comment';
button.setAttribute('aria-label', 'Add comment');
button.innerHTML = `
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
<path d="M12 8v4"></path>
<path d="M10 10h4"></path>
<path d="M12 8v4"></path><path d="M10 10h4"></path>
</svg>
<span class="comment-anchor-count" aria-hidden="true" hidden></span>
<span class="sr-only">Add comment</span>
`;
btn.addEventListener('click', () => promptComment(el, hash));
el.appendChild(btn);
<span class="sr-only">Add comment</span>`;
button.addEventListener('click', () => createComment(anchorHash));
el.appendChild(button);
}
function updateCommentButtonState(el, count) {
const btn = el.querySelector('.comment-anchor-btn');
if (!btn) return;
const hasComments = count > 0;
btn.classList.toggle('comment-anchor-btn--has-comments', hasComments);
btn.setAttribute('aria-label', hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment');
btn.title = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
const countNode = btn.querySelector('.comment-anchor-count');
if (countNode) {
countNode.hidden = !hasComments;
countNode.textContent = count > 99 ? '99+' : String(count);
function updateCommentButtonState(el, comments) {
const button = el.querySelector('.comment-anchor-btn');
const count = comments.filter(comment => !comment.resolvedAt).length;
if (button) {
const hasComments = count > 0;
button.classList.toggle('comment-anchor-btn--has-comments', hasComments);
const label = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
button.setAttribute('aria-label', label);
button.title = label;
const countNode = button.querySelector('.comment-anchor-count');
if (countNode) {
countNode.hidden = !hasComments;
countNode.textContent = count > 99 ? '99+' : String(count);
}
}
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false';
}
async function promptComment(el, anchorHash) {
const text = window.prompt('Comment on this paragraph:');
if (!text || !text.trim()) return;
async function createComment(anchorHash, parentId) {
const promptText = parentId ? 'Reply to this comment:' : 'Comment on this section:';
const content = window.prompt(promptText);
if (!content || !content.trim()) return;
try {
const res = await fetch('/api/comments', {
const response = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId, versionHash: documentHash, content: text.trim(), anchorHash }),
body: JSON.stringify({
documentId,
versionHash: documentHash,
content: content.trim(),
anchorHash,
parentId: parentId || undefined,
}),
});
if (!res.ok) throw new Error(await res.text());
await loadCommentsForAnchor(el, anchorHash);
if (!response.ok) throw new Error(await response.text());
await loadCommentsForAnchor(anchorHash);
} catch (err) {
window.alert('Failed to post comment: ' + err.message);
}
}
async function loadCommentsForAnchor(el, anchorHash) {
async function resolveThread(comment, anchorHash) {
if (!window.confirm('Resolve this comment thread?')) return;
try {
const res = await fetch(`/api/comments?document_id=${encodeURIComponent(documentId)}&anchor_hash=${encodeURIComponent(anchorHash)}`);
if (!res.ok) return;
const data = await res.json();
renderComments(el, data.comments || []);
const response = await fetch(`/api/comments/${encodeURIComponent(comment.id)}/resolve`, { method: 'POST' });
if (!response.ok) throw new Error(await response.text());
await loadCommentsForAnchor(anchorHash);
} catch (err) {
window.alert('Failed to resolve comment: ' + err.message);
}
}
async function loadCommentsForAnchor(anchorHash) {
const element = elementsByAnchor.get(anchorHash);
if (!element) return;
const params = new URLSearchParams({ document_id: documentId, anchor_hash: anchorHash });
if (includeResolved) params.set('include_resolved', 'true');
try {
const response = await fetch(`/api/comments?${params}`);
if (!response.ok) return;
const data = await response.json();
const comments = data.comments || [];
commentsByAnchor.set(anchorHash, comments);
renderInlineComments(element, anchorHash, comments);
renderCommentsAside();
updateStripMarkers();
} catch (err) {
console.error('load comments', err);
}
}
function renderComments(el, comments) {
const existing = el.querySelector('.comment-list');
if (existing) existing.remove();
updateCommentButtonState(el, comments.length);
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false';
if (!comments.length) {
renderCommentsAside();
updateStripMarkers();
return;
}
const list = document.createElement('div');
list.className = 'comment-list';
comments.forEach(c => {
const item = document.createElement('div');
item.className = 'comment-item';
item.innerHTML = `
<div class="comment-meta"><strong>${escapeHtml(c.authorName || 'Anonymous')}</strong><time>${formatDate(c.createdAt)}</time></div>
<div class="comment-body">${escapeHtml(c.content)}</div>
`;
list.appendChild(item);
function buildCommentForest(comments) {
const nodes = new Map(comments.map(comment => [comment.id, { comment, children: [] }]));
const roots = [];
comments.forEach(comment => {
const node = nodes.get(comment.id);
const parent = comment.parentId ? nodes.get(comment.parentId) : null;
if (parent) parent.children.push(node);
else roots.push(node);
});
el.appendChild(list);
renderCommentsAside();
updateStripMarkers();
return roots;
}
function buildAsideItemFor(el) {
const comments = [];
el.querySelectorAll('.comment-item').forEach(item => {
const meta = item.querySelector('.comment-meta');
const bodyEl = item.querySelector('.comment-body');
comments.push({
authorName: meta?.querySelector('strong')?.textContent || 'Anonymous',
createdAt: meta?.querySelector('time')?.textContent || '',
content: bodyEl?.textContent || '',
});
});
function renderThreadNode(node, anchorHash, variant) {
const comment = node.comment;
const wrapper = document.createElement('div');
wrapper.className = `comment-thread${comment.resolvedAt ? ' is-resolved' : ''}`;
wrapper.dataset.commentId = comment.id;
const item = document.createElement('div');
item.className = variant === 'aside' ? 'comments-aside-item__comment' : 'comment-item';
const meta = document.createElement('div');
meta.className = variant === 'aside' ? 'comments-aside-item__meta' : 'comment-meta';
const author = document.createElement('strong');
author.textContent = comment.authorName || 'Anonymous';
const time = document.createElement('time');
time.textContent = formatDate(comment.createdAt);
meta.append(author, time);
if (comment.resolvedAt) {
const status = document.createElement('span');
status.className = 'comment-status';
status.textContent = 'Resolved';
meta.appendChild(status);
}
const body = document.createElement('div');
body.className = variant === 'aside' ? 'comments-aside-item__body' : 'comment-body';
body.textContent = comment.content;
item.append(meta, body);
if (canComment && !comment.resolvedAt) {
const actions = document.createElement('div');
actions.className = 'comment-actions';
const reply = document.createElement('button');
reply.type = 'button';
reply.className = 'comment-action';
reply.textContent = 'Reply';
reply.addEventListener('click', () => createComment(anchorHash, comment.id));
actions.appendChild(reply);
if (!comment.parentId) {
const resolve = document.createElement('button');
resolve.type = 'button';
resolve.className = 'comment-action';
resolve.textContent = 'Resolve thread';
resolve.addEventListener('click', () => resolveThread(comment, anchorHash));
actions.appendChild(resolve);
}
item.appendChild(actions);
}
wrapper.appendChild(item);
if (node.children.length) {
const replies = document.createElement('div');
replies.className = 'comment-thread__replies';
node.children.forEach(child => replies.appendChild(renderThreadNode(child, anchorHash, variant)));
wrapper.appendChild(replies);
}
return wrapper;
}
function renderInlineComments(el, anchorHash, comments) {
el.querySelector('.comment-list')?.remove();
updateCommentButtonState(el, comments);
if (!comments.length) return;
const list = document.createElement('div');
list.className = 'comment-list';
buildCommentForest(comments).forEach(root => list.appendChild(renderThreadNode(root, anchorHash, 'inline')));
el.appendChild(list);
}
function buildAsideItem(anchorHash, el, comments) {
if (!comments.length) return null;
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
const truncated = targetText.length > 120 ? targetText + '…' : targetText;
const asideItem = document.createElement('div');
asideItem.className = 'comments-aside-item';
asideItem.dataset.anchorHash = el.dataset.anchorHash;
const targetEl = document.createElement('div');
targetEl.className = 'comments-aside-item__target';
targetEl.textContent = truncated || 'Commented section';
targetEl.addEventListener('click', () => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
asideItem.appendChild(targetEl);
comments.forEach(c => {
const commentEl = document.createElement('div');
commentEl.className = 'comments-aside-item__comment';
commentEl.innerHTML = `
<div class="comments-aside-item__meta"><strong>${escapeHtml(c.authorName)}</strong><time>${escapeHtml(c.createdAt)}</time></div>
<div class="comments-aside-item__body">${escapeHtml(c.content)}</div>
`;
asideItem.appendChild(commentEl);
});
asideItem.dataset.anchorHash = anchorHash;
const target = document.createElement('button');
target.className = 'comments-aside-item__target';
target.type = 'button';
target.textContent = el.dataset.commentTarget || 'Commented section';
target.addEventListener('click', () => el.scrollIntoView({ behavior: 'smooth', block: 'center' }));
asideItem.appendChild(target);
buildCommentForest(comments).forEach(root => asideItem.appendChild(renderThreadNode(root, anchorHash, 'aside')));
return asideItem;
}
function renderCommentsInto(container) {
if (!container) return;
container.innerHTML = '';
const body = document.querySelector('.markdown-body');
if (!body) return;
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
if (!commentables.length) {
container.innerHTML = '<p class="document-comments-aside__empty">No comments yet.</p>';
return;
}
commentables.forEach(el => {
const item = buildAsideItemFor(el);
if (item) container.appendChild(item);
let rendered = 0;
elementsByAnchor.forEach((el, anchorHash) => {
const item = buildAsideItem(anchorHash, el, commentsByAnchor.get(anchorHash) || []);
if (item) {
container.appendChild(item);
rendered++;
}
});
if (!rendered) {
container.innerHTML = `<p class="document-comments-aside__empty">${includeResolved ? 'No comments found.' : 'No active comments yet.'}</p>`;
}
}
function renderCommentsAside() {
@@ -237,177 +276,148 @@
function updateStripMarkers() {
if (!stripTrack) return;
stripTrack.innerHTML = '';
const body = document.querySelector('.markdown-body');
const shell = document.querySelector('.document-shell');
if (!body || !shell) return;
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
const shellRect = shell.getBoundingClientRect();
const trackHeight = stripTrack.clientHeight;
commentables.forEach(el => {
if (!shell) return;
elementsByAnchor.forEach((el, anchorHash) => {
if (!(commentsByAnchor.get(anchorHash) || []).length) return;
const rect = el.getBoundingClientRect();
const shellRect = shell.getBoundingClientRect();
const relativeTop = rect.top - shellRect.top + shell.scrollTop;
const percent = (relativeTop / shell.scrollHeight) * 100;
const clamped = Math.max(0, Math.min(95, percent));
const marker = document.createElement('button');
marker.className = 'document-comments-strip__marker';
marker.type = 'button';
marker.style.top = clamped + '%';
marker.dataset.anchorHash = el.dataset.anchorHash;
marker.style.top = Math.max(0, Math.min(95, percent)) + '%';
marker.dataset.anchorHash = anchorHash;
marker.title = 'Jump to comments';
marker.addEventListener('click', () => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (commentsHidden()) setCommentsHidden(false);
// Highlight the corresponding sidebar item
const asideItem = commentsAsideList?.querySelector(`[data-anchor-hash="${el.dataset.anchorHash}"]`);
if (asideItem) {
asideItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
asideItem.classList.add('is-highlighted');
setTimeout(() => asideItem.classList.remove('is-highlighted'), 1500);
}
commentsAsideList?.querySelector(`[data-anchor-hash="${anchorHash}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
stripTrack.appendChild(marker);
});
}
let bidirectionalHoverInitialized = false;
function initBidirectionalHover() {
if (bidirectionalHoverInitialized) return;
bidirectionalHoverInitialized = true;
const body = document.querySelector('.markdown-body');
if (!body || !commentsAsideList) return;
// Body → sidebar + marker
body.addEventListener('mouseenter', (e) => {
const el = e.target.closest('.commentable[data-has-comments="true"]');
if (!el) return;
const hash = el.dataset.anchorHash;
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
if (asideItem) asideItem.classList.add('is-highlighted');
if (marker) marker.classList.add('is-active');
el.classList.add('is-highlighted');
}, true);
body.addEventListener('mouseleave', (e) => {
const el = e.target.closest('.commentable[data-has-comments="true"]');
if (!el) return;
const hash = el.dataset.anchorHash;
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
if (asideItem) asideItem.classList.remove('is-highlighted');
if (marker) marker.classList.remove('is-active');
el.classList.remove('is-highlighted');
}, true);
// Sidebar → paragraph + marker (event delegation so it survives re-renders)
commentsAsideList.addEventListener('mouseenter', (e) => {
const item = e.target.closest('.comments-aside-item');
if (!item) return;
const hash = item.dataset.anchorHash;
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
if (para) para.classList.add('is-highlighted');
if (marker) marker.classList.add('is-active');
item.classList.add('is-highlighted');
}, true);
commentsAsideList.addEventListener('mouseleave', (e) => {
const item = e.target.closest('.comments-aside-item');
if (!item) return;
const hash = item.dataset.anchorHash;
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
if (para) para.classList.remove('is-highlighted');
if (marker) marker.classList.remove('is-active');
item.classList.remove('is-highlighted');
}, true);
body.addEventListener('mouseover', event => toggleHighlight(event.target.closest('.commentable'), true));
body.addEventListener('mouseout', event => toggleHighlight(event.target.closest('.commentable'), false));
commentsAsideList.addEventListener('mouseover', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), true));
commentsAsideList.addEventListener('mouseout', event => toggleAsideHighlight(event.target.closest('.comments-aside-item'), false));
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
function toggleHighlight(el, active) {
if (!el) return;
const hash = el.dataset.anchorHash;
el.classList.toggle('is-highlighted', active);
commentsAsideList?.querySelector(`[data-anchor-hash="${hash}"]`)?.classList.toggle('is-highlighted', active);
stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`)?.classList.toggle('is-active', active);
}
function formatDate(iso) {
const d = new Date(iso);
return d.toLocaleString();
}
function commentsSheetOpen() {
return commentsAside?.classList.contains('is-open');
function toggleAsideHighlight(item, active) {
if (!item) return;
toggleHighlight(elementsByAnchor.get(item.dataset.anchorHash), active);
}
function setCommentsSheetOpen(open) {
if (!commentsAside) return;
commentsAside.classList.toggle('is-open', open);
if (commentsSheetToggle) {
commentsSheetToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
commentsSheetToggle.setAttribute('aria-label', open ? 'Hide comments' : 'Show comments');
}
commentsSheetToggle?.setAttribute('aria-expanded', String(open));
if (open) {
document.body.classList.add('drawer-open');
renderCommentsAside();
} else {
if (!pickerOpen() && !metaOpen()) {
document.body.classList.remove('drawer-open');
}
} else if (!document.querySelector('[data-picker-drawer].is-open, [data-meta-drawer].is-open')) {
document.body.classList.remove('drawer-open');
}
}
function pickerOpen() {
return document.querySelector('[data-picker-drawer]')?.classList.contains('is-open');
commentsSheetToggle?.addEventListener('click', () => setCommentsSheetOpen(!commentsAside?.classList.contains('is-open')));
commentsSheetClose?.addEventListener('click', () => setCommentsSheetOpen(false));
function setWatchState(nextWatching) {
watching = nextWatching;
if (!watchButton) return;
watchButton.setAttribute('aria-pressed', String(watching));
const label = watchButton.querySelector('[data-document-watch-label]');
if (label) label.textContent = watching ? 'Unwatch document' : 'Watch document';
}
function metaOpen() {
return document.querySelector('[data-meta-drawer]')?.classList.contains('is-open');
async function loadWatchStatus() {
if (!watchButton) return;
try {
const response = await fetch(`/api/watch/status?document_id=${encodeURIComponent(documentId)}`);
if (!response.ok) return;
const data = await response.json();
setWatchState(Boolean(data.watching));
} catch (err) {
console.error('load watch status', err);
}
}
if (commentsSheetToggle) {
commentsSheetToggle.addEventListener('click', () => {
setCommentsSheetOpen(!commentsSheetOpen());
async function toggleWatch() {
if (!watchButton) return;
watchButton.disabled = true;
try {
const response = await fetch('/api/watch/document', {
method: watching ? 'DELETE' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ documentId }),
});
if (!response.ok) throw new Error(await response.text());
setWatchState(!watching);
} catch (err) {
window.alert('Failed to update watch: ' + err.message);
} finally {
watchButton.disabled = false;
}
}
watchButton?.addEventListener('click', toggleWatch);
function updateHistoryButtons() {
historyButtons.forEach(button => {
button.setAttribute('aria-pressed', String(includeResolved));
button.textContent = includeResolved ? 'Hide resolved' : 'Show resolved';
});
}
if (commentsSheetClose) {
commentsSheetClose.addEventListener('click', () => setCommentsSheetOpen(false));
historyButtons.forEach(button => button.addEventListener('click', async () => {
includeResolved = !includeResolved;
updateHistoryButtons();
await Promise.all(Array.from(elementsByAnchor.keys(), loadCommentsForAnchor));
}));
function formatDate(iso) {
return new Date(iso).toLocaleString();
}
function init() {
const body = document.querySelector('.markdown-body');
if (!body) return;
const paragraphs = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
paragraphs.forEach(el => {
const hash = hashParagraph(el);
if (!hash) return;
el.dataset.anchorHash = hash;
const commentables = body.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre');
commentables.forEach(el => {
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim();
const anchorHash = hashParagraph(el);
if (!anchorHash) return;
el.dataset.anchorHash = anchorHash;
el.dataset.commentTarget = targetText.length > 120 ? targetText.slice(0, 120) + '…' : targetText;
el.classList.add('commentable');
if (canComment) addCommentButton(el, hash);
loadCommentsForAnchor(el, hash);
elementsByAnchor.set(anchorHash, el);
if (canComment) addCommentButton(el, anchorHash);
loadCommentsForAnchor(anchorHash);
});
renderCommentsAside();
initBidirectionalHover();
// Update markers on scroll/resize
loadWatchStatus();
updateHistoryButtons();
const shell = document.querySelector('.document-shell');
if (shell) {
shell.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
}
shell?.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers));
// Initial marker update after layout settles
setTimeout(updateStripMarkers, 100);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();

View File

@@ -7,6 +7,7 @@
const trigger = bell.querySelector('.notification-bell__trigger');
const countEl = bell.querySelector('[data-unread-count]');
const dropdown = bell.querySelector('[data-notification-dropdown]');
if (!trigger || !countEl || !dropdown) return;
let isOpen = false;
trigger.addEventListener('click', (e) => {
@@ -76,31 +77,42 @@
notifications.forEach(n => {
const li = document.createElement('li');
li.className = n.readAt ? 'notification-item is-read' : 'notification-item';
li.innerHTML = `
const button = document.createElement('button');
button.type = 'button';
button.className = 'notification-item__button';
button.innerHTML = `
<div class="notification-message">${escapeHtml(n.message)}</div>
<time class="notification-time">${formatDate(n.createdAt)}</time>
`;
if (!n.readAt) {
li.addEventListener('click', () => markRead(n.id));
}
button.addEventListener('click', async () => {
if (!n.readAt) await markRead(n.id, false);
const target = notificationURL(n);
if (target) window.location.assign(target);
});
li.appendChild(button);
list.appendChild(li);
});
// Add "Mark all read" button
const footer = document.createElement('li');
footer.className = 'notification-footer';
const btn = document.createElement('button');
btn.textContent = 'Mark all read';
btn.addEventListener('click', markAllRead);
footer.appendChild(btn);
list.appendChild(footer);
if (notifications.some(notification => !notification.readAt)) {
const footer = document.createElement('li');
footer.className = 'notification-footer';
const btn = document.createElement('button');
btn.textContent = 'Mark all read';
btn.addEventListener('click', markAllRead);
footer.appendChild(btn);
list.appendChild(footer);
}
}
async function markRead(id) {
async function markRead(id, refresh = true) {
try {
await fetch(`/api/notifications/${id}/read`, { method: 'POST' });
updateBell();
loadNotifications();
const response = await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: 'POST' });
if (!response.ok) throw new Error(await response.text());
if (refresh) {
updateBell();
loadNotifications();
}
} catch (err) {
console.error('mark read', err);
}
@@ -108,7 +120,8 @@
async function markAllRead() {
try {
await fetch('/api/notifications/read-all', { method: 'POST' });
const response = await fetch('/api/notifications/read-all', { method: 'POST' });
if (!response.ok) throw new Error(await response.text());
updateBell();
loadNotifications();
} catch (err) {
@@ -127,6 +140,12 @@
return d.toLocaleString();
}
function notificationURL(notification) {
if (notification.resourceType !== 'document' || !notification.resourceId) return '';
const path = notification.resourceId.replace(/^doc:/, '').split('/').map(encodeURIComponent).join('/');
return '/docs/' + path;
}
// Listen for WebSocket notification events
if (window.__cairnquireRealtime) {
window.__cairnquireRealtime.on('notification', () => {

View File

@@ -198,19 +198,27 @@ code {
}
.notification-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.1s;
}
.notification-item__button {
display: block;
width: 100%;
padding: 0.75rem 1rem;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.notification-item:hover {
background: var(--accent-soft);
}
.notification-item.is-read {
opacity: 0.7;
cursor: default;
}
.notification-message {
@@ -1199,6 +1207,27 @@ code {
padding: 0.5rem 0.75rem;
}
.comments-toolbar {
display: flex;
justify-content: flex-end;
padding: 0.45rem 0.75rem;
border-bottom: 1px solid var(--border);
}
.comments-toolbar button,
.comment-action {
padding: 0;
border: 0;
background: transparent;
color: var(--accent);
font: inherit;
cursor: pointer;
}
.comments-toolbar button {
font-size: 0.75rem;
}
/* Bidirectional hover highlighting */
.comments-aside-item {
margin-bottom: 0.5rem;
@@ -1216,6 +1245,13 @@ code {
}
.comments-aside-item__target {
display: block;
width: calc(100% + 1.2rem);
text-align: left;
font: inherit;
border-top: 0;
border-right: 0;
border-left: 0;
margin: -0.6rem -0.6rem 0.4rem;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border);
@@ -1259,6 +1295,39 @@ code {
border-bottom: 0;
}
.comment-thread__replies {
margin-left: 0.7rem;
padding-left: 0.65rem;
border-left: 2px solid var(--border);
}
.comment-thread.is-resolved {
opacity: 0.7;
}
.comment-status {
padding: 0.05rem 0.3rem;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 0.68rem;
}
.comment-actions {
display: flex;
gap: 0.65rem;
margin-top: 0.3rem;
}
.comment-action {
font-size: 0.75rem;
}
.comment-action:hover,
.comments-toolbar button:hover {
text-decoration: underline;
}
.document-comments-aside__empty {
margin: 0;
padding: 1rem 0;

View File

@@ -107,6 +107,7 @@
<script src="/static/cache.js" defer></script>
<script src="/static/sync.js" defer></script>
<script src="/static/realtime.js" defer></script>
<script src="/static/notification-bell.js" defer></script>
<script src="/static/editor.js" defer></script>
<script src="/static/auth.js" defer></script>
<script src="/static/render.js" defer></script>

View File

@@ -4,7 +4,7 @@
<section class="workspace-shell workspace-shell--document">
<div class="mobile-drawer-backdrop" data-mobile-drawer-backdrop aria-hidden="true"></div>
{{ template "browser" .Browser }}
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}" data-can-comment="{{ .CanComment }}">
<article class="document-shell" data-document-id="{{ .ID }}" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}" data-can-comment="{{ .CanComment }}">
<div class="document-mobile-bar">
<button class="document-mobile-bar__btn document-mobile-bar__btn--picker" type="button" data-toggle-picker aria-label="Open file picker" aria-expanded="false">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line></svg>
@@ -43,6 +43,10 @@
</svg>
</summary>
<div class="document-actions-dropdown__menu">
<button class="document-actions-dropdown__item" type="button" data-document-watch aria-pressed="false">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"></path></svg>
<span data-document-watch-label>Watch document</span>
</button>
{{ if .CanWrite }}
<a class="document-actions-dropdown__item" href="/docs/{{ trimMd .Path }}/edit">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
@@ -106,6 +110,9 @@
<div class="document-comments-strip__track" data-comments-strip-track></div>
</div>
<div class="document-comments-panel">
<div class="comments-toolbar">
<button type="button" data-comments-history aria-pressed="false">Show resolved</button>
</div>
<div class="document-comments-panel__list" data-comments-aside-list></div>
</div>
@@ -126,6 +133,9 @@
</button>
</div>
<div class="document-comments-sheet__panel">
<div class="comments-toolbar">
<button type="button" data-comments-history aria-pressed="false">Show resolved</button>
</div>
<div class="document-comments-panel__list" data-comments-aside-list-mobile></div>
</div>
</div>