Add Cloudflare email and collaboration workflows
This commit is contained in:
@@ -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, `
|
||||
|
||||
157
apps/server/internal/collaboration/repository_test.go
Normal file
157
apps/server/internal/collaboration/repository_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user