diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..91f473d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.DS_Store + +data +tmp + +**/node_modules +**/dist +**/.vite + +coverage +*.log diff --git a/apps/server/internal/app/app.go b/apps/server/internal/app/app.go index 1be1ab8..485c54d 100644 --- a/apps/server/internal/app/app.go +++ b/apps/server/internal/app/app.go @@ -7,10 +7,13 @@ import ( "log/slog" "net/http" "os" + "strings" "time" "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/email" "github.com/tim/cairnquire/apps/server/internal/database" "github.com/tim/cairnquire/apps/server/internal/docs" "github.com/tim/cairnquire/apps/server/internal/httpserver" @@ -52,10 +55,6 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err repo := docs.NewRepository(db.SQL()) service := docs.NewService(cfg.Content.SourceDir, contentStore, renderer, repo, logger) hub := realtime.NewHub(logger) - service.OnChange(func(change docs.DocumentChange) { - hub.Broadcast(realtime.Event{Type: "document_version", Data: change}) - }) - if _, err := service.SyncSourceDir(ctx); err != nil { logger.Warn("initial content sync failed", "error", err) } @@ -68,16 +67,33 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err return nil, fmt.Errorf("build auth service: %w", err) } + collabRepo := collaboration.NewRepository(db.SQL()) + var emailSender collaboration.EmailSender + if cfg.Email.SMTPHost != "" { + emailSender = email.NewSMTPSender(cfg.Email, logger) + } else { + emailSender = email.NewNoOpSender(logger) + } + collabService := collaboration.NewService(collabRepo, emailSender, hub, logger) + + service.OnChange(func(change docs.DocumentChange) { + hub.Broadcast(realtime.Event{Type: "document_version", Data: change}) + if err := collabService.NotifyDocumentChanged(ctx, formatDocumentID(change.Path), change.Path, "system"); err != nil { + logger.Warn("notify document changed", "error", err) + } + }) + handler, err := httpserver.New(httpserver.Dependencies{ - Config: cfg, - Logger: logger, - Documents: service, - Repository: repo, - ContentStore: contentStore, - Hub: hub, - SyncService: syncService, - SyncRepo: syncRepo, - Auth: authService, + Config: cfg, + Logger: logger, + Documents: service, + Repository: repo, + ContentStore: contentStore, + Hub: hub, + SyncService: syncService, + SyncRepo: syncRepo, + Auth: authService, + Collaboration: collabService, }) if err != nil { return nil, fmt.Errorf("build http handler: %w", err) @@ -158,3 +174,9 @@ func (a *App) syncPoll(ctx context.Context) { func (a *App) Close() error { return a.db.Close() } + +func formatDocumentID(path string) string { + clean := strings.TrimPrefix(path, "/") + clean = strings.TrimSuffix(clean, ".md") + return "doc:" + clean +} diff --git a/apps/server/internal/collaboration/repository.go b/apps/server/internal/collaboration/repository.go new file mode 100644 index 0000000..b54c316 --- /dev/null +++ b/apps/server/internal/collaboration/repository.go @@ -0,0 +1,304 @@ +package collaboration + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +type Repository struct { + db *sql.DB +} + +func NewRepository(db *sql.DB) *Repository { + return &Repository{db: db} +} + +// Comments + +func (r *Repository) CreateComment(ctx context.Context, comment Comment) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO comments (id, document_id, version_hash, parent_id, author_id, content, anchor_hash, anchor_line, created_at, resolved_at, resolved_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, comment.ID, comment.DocumentID, comment.VersionHash, comment.ParentID, comment.AuthorID, comment.Content, comment.AnchorHash, comment.AnchorLine, formatTime(comment.CreatedAt), comment.ResolvedAt, comment.ResolvedBy) + if err != nil { + return fmt.Errorf("insert comment: %w", err) + } + return nil +} + +func (r *Repository) GetComment(ctx context.Context, id string) (*Comment, error) { + var c Comment + var parentID, anchorHash sql.NullString + var anchorLine sql.NullInt64 + var resolvedAt, resolvedBy sql.NullString + var createdAt string + + err := r.db.QueryRowContext(ctx, ` + 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.id = ? + `, id).Scan( + &c.ID, &c.DocumentID, &c.VersionHash, &parentID, &c.AuthorID, &c.AuthorName, + &c.Content, &anchorHash, &anchorLine, &createdAt, &resolvedAt, &resolvedBy, + ) + if err != nil { + return nil, err + } + + c.ParentID = nullString(parentID.String) + c.AnchorHash = nullString(anchorHash.String) + if anchorLine.Valid { + c.AnchorLine = ptr(int(anchorLine.Int64)) + } + c.CreatedAt, _ = parseTime(createdAt) + if resolvedAt.Valid && resolvedAt.String != "" { + t, _ := parseTime(resolvedAt.String) + c.ResolvedAt = &t + c.ResolvedBy = nullString(resolvedBy.String) + } + + return &c, nil +} + +func (r *Repository) ListCommentsByDocument(ctx context.Context, documentID string) ([]Comment, error) { + rows, err := r.db.QueryContext(ctx, ` + 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) + if err != nil { + return nil, fmt.Errorf("list comments: %w", err) + } + defer rows.Close() + + return scanComments(rows) +} + +func (r *Repository) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) { + rows, err := r.db.QueryContext(ctx, ` + 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) + if err != nil { + return nil, fmt.Errorf("list comments by anchor: %w", err) + } + defer rows.Close() + + return scanComments(rows) +} + +func (r *Repository) ResolveComment(ctx context.Context, commentID string, userID string) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE comments SET resolved_at = ?, resolved_by = ? + WHERE id = ? + `, formatTime(time.Now().UTC()), userID, commentID) + if err != nil { + return fmt.Errorf("resolve comment: %w", err) + } + return nil +} + +func scanComments(rows *sql.Rows) ([]Comment, error) { + var comments []Comment + for rows.Next() { + var c Comment + var parentID, anchorHash sql.NullString + var anchorLine sql.NullInt64 + var resolvedAt, resolvedBy sql.NullString + var createdAt string + + if err := rows.Scan( + &c.ID, &c.DocumentID, &c.VersionHash, &parentID, &c.AuthorID, &c.AuthorName, + &c.Content, &anchorHash, &anchorLine, &createdAt, &resolvedAt, &resolvedBy, + ); err != nil { + return nil, fmt.Errorf("scan comment: %w", err) + } + + c.ParentID = nullString(parentID.String) + c.AnchorHash = nullString(anchorHash.String) + if anchorLine.Valid { + c.AnchorLine = ptr(int(anchorLine.Int64)) + } + c.CreatedAt, _ = parseTime(createdAt) + if resolvedAt.Valid && resolvedAt.String != "" { + t, _ := parseTime(resolvedAt.String) + c.ResolvedAt = &t + c.ResolvedBy = nullString(resolvedBy.String) + } + comments = append(comments, c) + } + return comments, rows.Err() +} + +// Notifications + +func (r *Repository) CreateNotification(ctx context.Context, n Notification) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO notifications (id, user_id, type, resource_type, resource_id, message, read_at, emailed_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, n.ID, n.UserID, n.Type, n.ResourceType, n.ResourceID, n.Message, n.ReadAt, n.EmailedAt, formatTime(n.CreatedAt)) + if err != nil { + return fmt.Errorf("insert notification: %w", err) + } + return nil +} + +func (r *Repository) ListNotifications(ctx context.Context, userID string, unreadOnly bool, limit int) ([]Notification, error) { + query := ` + SELECT id, user_id, type, resource_type, resource_id, message, read_at, emailed_at, created_at + FROM notifications + WHERE user_id = ?` + args := []any{userID} + if unreadOnly { + query += ` AND read_at IS NULL` + } + query += ` ORDER BY created_at DESC` + if limit > 0 { + query += ` LIMIT ?` + args = append(args, limit) + } + + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list notifications: %w", err) + } + defer rows.Close() + + var notifications []Notification + for rows.Next() { + var n Notification + var readAt, emailedAt sql.NullString + var createdAt string + if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &n.ResourceType, &n.ResourceID, &n.Message, &readAt, &emailedAt, &createdAt); err != nil { + return nil, fmt.Errorf("scan notification: %w", err) + } + if readAt.Valid && readAt.String != "" { + t, _ := parseTime(readAt.String) + n.ReadAt = &t + } + if emailedAt.Valid && emailedAt.String != "" { + t, _ := parseTime(emailedAt.String) + n.EmailedAt = &t + } + n.CreatedAt, _ = parseTime(createdAt) + notifications = append(notifications, n) + } + return notifications, rows.Err() +} + +func (r *Repository) CountUnreadNotifications(ctx context.Context, userID string) (int, error) { + var count int + err := r.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM notifications WHERE user_id = ? AND read_at IS NULL + `, userID).Scan(&count) + if err != nil { + return 0, fmt.Errorf("count unread notifications: %w", err) + } + return count, nil +} + +func (r *Repository) MarkNotificationRead(ctx context.Context, notificationID string, userID string) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE notifications SET read_at = ? WHERE id = ? AND user_id = ? + `, formatTime(time.Now().UTC()), notificationID, userID) + if err != nil { + return fmt.Errorf("mark notification read: %w", err) + } + return nil +} + +func (r *Repository) MarkAllNotificationsRead(ctx context.Context, userID string) error { + _, err := r.db.ExecContext(ctx, ` + UPDATE notifications SET read_at = ? WHERE user_id = ? AND read_at IS NULL + `, formatTime(time.Now().UTC()), userID) + if err != nil { + return fmt.Errorf("mark all notifications read: %w", err) + } + return nil +} + +// Watchers + +func (r *Repository) AddWatcher(ctx context.Context, w Watcher) error { + _, err := r.db.ExecContext(ctx, ` + INSERT 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) + } + return nil +} + +func (r *Repository) RemoveWatcher(ctx context.Context, userID string, documentID *string, folderPath *string) error { + _, err := r.db.ExecContext(ctx, ` + DELETE FROM watchers WHERE user_id = ? AND document_id IS ? AND folder_path IS ? + `, userID, documentID, folderPath) + if err != nil { + return fmt.Errorf("remove watcher: %w", err) + } + return nil +} + +func (r *Repository) ListWatchersByDocument(ctx context.Context, documentID string) ([]Watcher, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT user_id, document_id, folder_path, created_at + FROM watchers + WHERE document_id = ? + `, documentID) + if err != nil { + return nil, fmt.Errorf("list document watchers: %w", err) + } + defer rows.Close() + return scanWatchers(rows) +} + +func (r *Repository) ListWatchersByFolder(ctx context.Context, folderPath 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) + if err != nil { + return nil, fmt.Errorf("list folder watchers: %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, ` + SELECT COUNT(*) FROM watchers WHERE user_id = ? AND document_id = ? + `, userID, documentID).Scan(&count) + if err != nil { + return false, fmt.Errorf("check watching: %w", err) + } + return count > 0, nil +} + +func scanWatchers(rows *sql.Rows) ([]Watcher, error) { + var watchers []Watcher + for rows.Next() { + var w Watcher + var docID, folderPath sql.NullString + var createdAt string + if err := rows.Scan(&w.UserID, &docID, &folderPath, &createdAt); err != nil { + return nil, fmt.Errorf("scan watcher: %w", err) + } + w.DocumentID = nullString(docID.String) + w.FolderPath = nullString(folderPath.String) + w.CreatedAt, _ = parseTime(createdAt) + watchers = append(watchers, w) + } + return watchers, rows.Err() +} diff --git a/apps/server/internal/collaboration/service.go b/apps/server/internal/collaboration/service.go new file mode 100644 index 0000000..a3a66fa --- /dev/null +++ b/apps/server/internal/collaboration/service.go @@ -0,0 +1,275 @@ +package collaboration + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/tim/cairnquire/apps/server/internal/auth" + "github.com/tim/cairnquire/apps/server/internal/realtime" +) + +type EmailSender interface { + Send(ctx context.Context, to []string, subject, body string) error +} + +type NoOpEmailSender struct{} + +func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body string) error { + slog.Info("email stub", "to", to, "subject", subject, "body_len", len(body)) + return nil +} + +type Service struct { + repo *Repository + email EmailSender + hub *realtime.Hub + logger *slog.Logger +} + +func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service { + if email == nil { + email = &NoOpEmailSender{} + } + return &Service{ + repo: repo, + email: email, + hub: hub, + logger: logger, + } +} + +// Comments + +func (s *Service) CreateComment(ctx context.Context, principal auth.Principal, input CreateCommentInput) (*Comment, error) { + if principal.UserID == "" { + return nil, fmt.Errorf("authentication required") + } + if input.Content == "" { + return nil, fmt.Errorf("content is required") + } + if input.DocumentID == "" { + return nil, fmt.Errorf("document_id is required") + } + + comment := Comment{ + ID: randomID("comment"), + DocumentID: input.DocumentID, + VersionHash: input.VersionHash, + AuthorID: principal.UserID, + Content: input.Content, + CreatedAt: time.Now().UTC(), + } + if input.ParentID != nil && *input.ParentID != "" { + comment.ParentID = input.ParentID + } + if input.AnchorHash != nil && *input.AnchorHash != "" { + comment.AnchorHash = input.AnchorHash + } + if input.AnchorLine != nil && *input.AnchorLine > 0 { + comment.AnchorLine = input.AnchorLine + } + + if err := s.repo.CreateComment(ctx, comment); err != nil { + return nil, err + } + + // Create notifications for watchers + if err := s.notifyWatchersOfComment(ctx, comment); 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) +} + +func (s *Service) ListComments(ctx context.Context, documentID string) ([]Comment, error) { + return s.repo.ListCommentsByDocument(ctx, documentID) +} + +func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, anchorHash string) ([]Comment, error) { + return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash) +} + +func (s *Service) ResolveComment(ctx context.Context, principal auth.Principal, commentID string) error { + if principal.UserID == "" { + return fmt.Errorf("authentication required") + } + return s.repo.ResolveComment(ctx, commentID, principal.UserID) +} + +// Notifications + +func (s *Service) ListNotifications(ctx context.Context, principal auth.Principal, unreadOnly bool, limit int) ([]Notification, error) { + if principal.UserID == "" { + return nil, fmt.Errorf("authentication required") + } + return s.repo.ListNotifications(ctx, principal.UserID, unreadOnly, limit) +} + +func (s *Service) GetBellStatus(ctx context.Context, principal auth.Principal) (*NotificationBell, error) { + if principal.UserID == "" { + return &NotificationBell{UnreadCount: 0, Items: []Notification{}}, nil + } + count, err := s.repo.CountUnreadNotifications(ctx, principal.UserID) + if err != nil { + return nil, err + } + items, err := s.repo.ListNotifications(ctx, principal.UserID, false, 20) + if err != nil { + return nil, err + } + return &NotificationBell{ + UnreadCount: count, + Items: items, + }, nil +} + +func (s *Service) MarkNotificationRead(ctx context.Context, principal auth.Principal, notificationID string) error { + if principal.UserID == "" { + return fmt.Errorf("authentication required") + } + return s.repo.MarkNotificationRead(ctx, notificationID, principal.UserID) +} + +func (s *Service) MarkAllNotificationsRead(ctx context.Context, principal auth.Principal) error { + if principal.UserID == "" { + return fmt.Errorf("authentication required") + } + return s.repo.MarkAllNotificationsRead(ctx, principal.UserID) +} + +// Watchers + +func (s *Service) WatchDocument(ctx context.Context, principal auth.Principal, documentID string) error { + if principal.UserID == "" { + return fmt.Errorf("authentication required") + } + return s.repo.AddWatcher(ctx, Watcher{ + UserID: principal.UserID, + DocumentID: &documentID, + CreatedAt: time.Now().UTC(), + }) +} + +func (s *Service) UnwatchDocument(ctx context.Context, principal auth.Principal, documentID string) error { + if principal.UserID == "" { + return fmt.Errorf("authentication required") + } + return s.repo.RemoveWatcher(ctx, principal.UserID, &documentID, nil) +} + +func (s *Service) WatchFolder(ctx context.Context, principal auth.Principal, folderPath string) error { + if principal.UserID == "" { + return fmt.Errorf("authentication required") + } + return s.repo.AddWatcher(ctx, Watcher{ + UserID: principal.UserID, + FolderPath: &folderPath, + CreatedAt: time.Now().UTC(), + }) +} + +func (s *Service) UnwatchFolder(ctx context.Context, principal auth.Principal, folderPath string) error { + if principal.UserID == "" { + return fmt.Errorf("authentication required") + } + return s.repo.RemoveWatcher(ctx, principal.UserID, nil, &folderPath) +} + +func (s *Service) IsWatching(ctx context.Context, principal auth.Principal, documentID string) (bool, error) { + if principal.UserID == "" { + return false, nil + } + return s.repo.IsWatching(ctx, principal.UserID, documentID) +} + +// Document change notifications + +func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string, documentPath string, actorName string) error { + watchers, err := s.repo.ListWatchersByDocument(ctx, documentID) + if err != nil { + return fmt.Errorf("list document watchers: %w", err) + } + folderWatchers, err := s.repo.ListWatchersByFolder(ctx, documentPath) + if err != nil { + return fmt.Errorf("list folder watchers: %w", err) + } + + seen := make(map[string]bool) + for _, w := range append(watchers, folderWatchers...) { + if seen[w.UserID] { + continue + } + seen[w.UserID] = true + + msg := fmt.Sprintf("%s updated %s", actorName, documentPath) + notification := Notification{ + ID: randomID("notif"), + UserID: w.UserID, + Type: "file_changed", + ResourceType: "document", + ResourceID: documentID, + Message: msg, + CreatedAt: time.Now().UTC(), + } + if err := s.repo.CreateNotification(ctx, notification); err != nil { + s.logger.Warn("create notification", "error", err) + continue + } + } + + // Broadcast bell update to all connected clients + s.broadcastNotification("") + + return nil +} + +func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) error { + watchers, err := s.repo.ListWatchersByDocument(ctx, comment.DocumentID) + if err != nil { + return err + } + + for _, w := range watchers { + if w.UserID == comment.AuthorID { + continue + } + msg := fmt.Sprintf("New comment on document") + if comment.AnchorHash != nil { + msg = fmt.Sprintf("New comment on a section of the document") + } + notification := Notification{ + ID: randomID("notif"), + UserID: w.UserID, + Type: "comment", + ResourceType: "document", + ResourceID: comment.DocumentID, + Message: msg, + CreatedAt: time.Now().UTC(), + } + if err := s.repo.CreateNotification(ctx, notification); err != nil { + s.logger.Warn("create comment notification", "error", err) + } + } + return nil +} + +func (s *Service) broadcastNotification(userID string) { + if s.hub == nil { + return + } + s.hub.Broadcast(realtime.Event{ + Type: "notification", + Data: map[string]string{"userId": userID}, + }) +} + +func (s *Service) SetEmailSender(email EmailSender) { + if email != nil { + s.email = email + } +} diff --git a/apps/server/internal/collaboration/types.go b/apps/server/internal/collaboration/types.go new file mode 100644 index 0000000..88d3acb --- /dev/null +++ b/apps/server/internal/collaboration/types.go @@ -0,0 +1,51 @@ +package collaboration + +import "time" + +type Comment struct { + ID string `json:"id"` + DocumentID string `json:"documentId"` + VersionHash string `json:"versionHash"` + ParentID *string `json:"parentId,omitempty"` + AuthorID string `json:"authorId"` + AuthorName string `json:"authorName"` + Content string `json:"content"` + AnchorHash *string `json:"anchorHash,omitempty"` + AnchorLine *int `json:"anchorLine,omitempty"` + CreatedAt time.Time `json:"createdAt"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` + ResolvedBy *string `json:"resolvedBy,omitempty"` +} + +type CreateCommentInput struct { + DocumentID string `json:"documentId"` + VersionHash string `json:"versionHash"` + ParentID *string `json:"parentId,omitempty"` + Content string `json:"content"` + AnchorHash *string `json:"anchorHash,omitempty"` + AnchorLine *int `json:"anchorLine,omitempty"` +} + +type Notification struct { + ID string `json:"id"` + UserID string `json:"userId"` + Type string `json:"type"` + ResourceType string `json:"resourceType"` + ResourceID string `json:"resourceId"` + Message string `json:"message"` + ReadAt *time.Time `json:"readAt,omitempty"` + EmailedAt *time.Time `json:"emailedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type Watcher struct { + UserID string `json:"userId"` + DocumentID *string `json:"documentId,omitempty"` + FolderPath *string `json:"folderPath,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type NotificationBell struct { + UnreadCount int `json:"unreadCount"` + Items []Notification `json:"items"` +} diff --git a/apps/server/internal/collaboration/util.go b/apps/server/internal/collaboration/util.go new file mode 100644 index 0000000..9105b5d --- /dev/null +++ b/apps/server/internal/collaboration/util.go @@ -0,0 +1,71 @@ +package collaboration + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "strings" + "time" +) + +func randomID(prefix string) string { + buf := make([]byte, 12) + if _, err := rand.Read(buf); err != nil { + panic(err) + } + return prefix + ":" + hex.EncodeToString(buf) +} + +func hashAnchor(text string) string { + normalized := strings.TrimSpace(text) + sum := sha256.Sum256([]byte(normalized)) + return hex.EncodeToString(sum[:8]) +} + +func parseTime(raw string) (time.Time, error) { + if raw == "" { + return time.Time{}, nil + } + return time.Parse(time.RFC3339, raw) +} + +func formatTime(t time.Time) string { + return t.UTC().Format(time.RFC3339) +} + +func ptr[T any](v T) *T { + return &v +} + +func nullString(s string) *string { + if s == "" { + return nil + } + return &s +} + +func scanNullString(ns *string) string { + if ns == nil { + return "" + } + return *ns +} + +func coalesceString(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +func formatDocumentID(path string) string { + clean := strings.TrimPrefix(path, "/") + clean = strings.TrimSuffix(clean, ".md") + return "doc:" + clean +} + +func documentPathFromID(id string) string { + return strings.TrimPrefix(id, "doc:") +} diff --git a/apps/server/internal/config/config.go b/apps/server/internal/config/config.go index e96c370..f01f56c 100644 --- a/apps/server/internal/config/config.go +++ b/apps/server/internal/config/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" ) type Config struct { @@ -13,6 +14,7 @@ type Config struct { Content ContentConfig `json:"content"` Web WebConfig `json:"web"` Auth AuthConfig `json:"auth"` + Email EmailConfig `json:"email"` LogLevel string `json:"logLevel"` } @@ -40,6 +42,12 @@ type AuthConfig struct { PublicOrigin string `json:"publicOrigin"` } +type EmailConfig struct { + SMTPHost string `json:"smtpHost"` + SMTPPort int `json:"smtpPort"` + From string `json:"from"` +} + func Default() Config { return Config{ Server: ServerConfig{ @@ -59,6 +67,11 @@ func Default() Config { Auth: AuthConfig{ PublicOrigin: "http://localhost:8080", }, + Email: EmailConfig{ + SMTPHost: "localhost", + SMTPPort: 1025, + From: "notifications@cairnquire.local", + }, LogLevel: "INFO", } } @@ -81,6 +94,13 @@ func Load() (Config, error) { overrideString(&cfg.Web.DistDir, "CAIRNQUIRE_WEB_DIST_DIR") overrideString(&cfg.Web.DevViteURL, "CAIRNQUIRE_DEV_VITE_URL") overrideString(&cfg.Auth.PublicOrigin, "CAIRNQUIRE_PUBLIC_ORIGIN") + overrideString(&cfg.Email.SMTPHost, "CAIRNQUIRE_EMAIL_SMTP_HOST") + if port := os.Getenv("CAIRNQUIRE_EMAIL_SMTP_PORT"); port != "" { + if p, err := strconv.Atoi(port); err == nil { + cfg.Email.SMTPPort = p + } + } + overrideString(&cfg.Email.From, "CAIRNQUIRE_EMAIL_FROM") overrideString(&cfg.LogLevel, "CAIRNQUIRE_LOG_LEVEL") if cfg.Server.Addr == "" { diff --git a/apps/server/internal/database/migrations/000010_collaboration.down.sql b/apps/server/internal/database/migrations/000010_collaboration.down.sql new file mode 100644 index 0000000..bfe0bc7 --- /dev/null +++ b/apps/server/internal/database/migrations/000010_collaboration.down.sql @@ -0,0 +1,11 @@ +DROP INDEX IF EXISTS idx_watchers_folder; +DROP INDEX IF EXISTS idx_watchers_document; +DROP TABLE IF EXISTS watchers; +DROP INDEX IF EXISTS idx_notifications_created_at; +DROP INDEX IF EXISTS idx_notifications_unread; +DROP INDEX IF EXISTS idx_notifications_user; +DROP TABLE IF EXISTS notifications; +DROP INDEX IF EXISTS idx_comments_author; +DROP INDEX IF EXISTS idx_comments_parent; +DROP INDEX IF EXISTS idx_comments_document; +DROP TABLE IF EXISTS comments; diff --git a/apps/server/internal/database/migrations/000010_collaboration.up.sql b/apps/server/internal/database/migrations/000010_collaboration.up.sql new file mode 100644 index 0000000..c60f496 --- /dev/null +++ b/apps/server/internal/database/migrations/000010_collaboration.up.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + version_hash TEXT NOT NULL, + parent_id TEXT REFERENCES comments(id) ON DELETE CASCADE, + author_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + content TEXT NOT NULL, + anchor_hash TEXT, + anchor_line INTEGER, + created_at TEXT NOT NULL, + resolved_at TEXT, + resolved_by TEXT REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_comments_document ON comments(document_id); +CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id); +CREATE INDEX IF NOT EXISTS idx_comments_author ON comments(author_id); + +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK(type IN ('file_changed', 'comment', 'mention', 'conflict')), + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + message TEXT NOT NULL, + read_at TEXT, + emailed_at TEXT, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, read_at) WHERE read_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_notifications_created_at ON notifications(created_at); + +CREATE TABLE IF NOT EXISTS watchers ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + document_id TEXT REFERENCES documents(id) ON DELETE CASCADE, + folder_path TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, document_id, folder_path) +); + +CREATE INDEX IF NOT EXISTS idx_watchers_document ON watchers(document_id); +CREATE INDEX IF NOT EXISTS idx_watchers_folder ON watchers(folder_path); diff --git a/apps/server/internal/email/sender.go b/apps/server/internal/email/sender.go new file mode 100644 index 0000000..553a7fd --- /dev/null +++ b/apps/server/internal/email/sender.go @@ -0,0 +1,67 @@ +package email + +import ( + "context" + "fmt" + "log/slog" + "net/smtp" + + "github.com/tim/cairnquire/apps/server/internal/config" +) + +type Sender interface { + Send(ctx context.Context, to []string, subject, body string) error +} + +type SMTPConfig struct { + Host string + Port int + From string +} + +type SMTPSender struct { + cfg SMTPConfig + logger *slog.Logger +} + +func NewSMTPSender(cfg config.EmailConfig, logger *slog.Logger) *SMTPSender { + return &SMTPSender{ + cfg: SMTPConfig{ + Host: cfg.SMTPHost, + Port: cfg.SMTPPort, + From: cfg.From, + }, + logger: logger, + } +} + +func (s *SMTPSender) Send(ctx context.Context, to []string, subject, body string) error { + if s.cfg.Host == "" { + return fmt.Errorf("email host not configured") + } + addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port) + msg := []byte(fmt.Sprintf("To: %s\r\nSubject: %s\r\n\r\n%s\r\n", to[0], subject, body)) + + s.logger.Info("sending email", "to", to, "subject", subject, "addr", addr) + + // Use SMTP without auth for local testing (Mailpit/MailHog) + var auth smtp.Auth + err := smtp.SendMail(addr, auth, s.cfg.From, to, msg) + if err != nil { + return fmt.Errorf("send email: %w", err) + } + return nil +} + +type NoOpSender struct { + logger *slog.Logger +} + +func NewNoOpSender(logger *slog.Logger) *NoOpSender { + return &NoOpSender{logger: logger} +} + +func (n *NoOpSender) Send(ctx context.Context, to []string, subject, body string) error { + n.logger.Info("email noop", "to", to, "subject", subject, "body_len", len(body)) + return nil +} diff --git a/apps/server/internal/httpserver/api_handlers_test.go b/apps/server/internal/httpserver/api_handlers_test.go new file mode 100644 index 0000000..d86de00 --- /dev/null +++ b/apps/server/internal/httpserver/api_handlers_test.go @@ -0,0 +1,390 @@ +package httpserver + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/tim/cairnquire/apps/server/internal/auth" + "github.com/tim/cairnquire/apps/server/internal/config" + "github.com/tim/cairnquire/apps/server/internal/database" + "github.com/tim/cairnquire/apps/server/internal/docs" + "github.com/tim/cairnquire/apps/server/internal/markdown" + "github.com/tim/cairnquire/apps/server/internal/realtime" + "github.com/tim/cairnquire/apps/server/internal/store" + "github.com/tim/cairnquire/apps/server/internal/sync" +) + +type apiTestServer struct { + handler http.Handler + auth *auth.Service + docs *docs.Service + userID string + root string +} + +func newAPITestServer(t *testing.T) *apiTestServer { + t.Helper() + + ctx := context.Background() + root := t.TempDir() + sourceDir := filepath.Join(root, "content") + storeDir := filepath.Join(root, "files") + if err := os.MkdirAll(sourceDir, 0o755); err != nil { + t.Fatalf("create source dir: %v", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, "hello.md"), []byte("# Hello\n\nInitial"), 0o644); err != nil { + t.Fatalf("write source fixture: %v", err) + } + + db, err := database.Open(ctx, config.DatabaseConfig{Path: filepath.Join(root, "db.sqlite")}) + if err != nil { + t.Fatalf("open test database: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := database.ApplyMigrations(ctx, db.SQL()); err != nil { + t.Fatalf("apply migrations: %v", err) + } + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + contentStore, err := store.New(storeDir) + if err != nil { + t.Fatalf("create content store: %v", err) + } + docRepo := docs.NewRepository(db.SQL()) + docService := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), docRepo, logger) + if _, err := docService.SyncSourceDir(ctx); err != nil { + t.Fatalf("sync source fixture: %v", err) + } + + authService, err := auth.NewService(auth.NewRepository(db.SQL()), "http://localhost:8080") + if err != nil { + t.Fatalf("create auth service: %v", err) + } + user, err := authService.RegisterPasswordUser(ctx, "editor@example.com", "Editor", "very secure password", string(auth.RoleEditor)) + if err != nil { + t.Fatalf("create test user: %v", err) + } + + syncRepo := sync.NewRepository(db.SQL()) + handler, err := New(Dependencies{ + Config: config.Config{ + Content: config.ContentConfig{ + SourceDir: sourceDir, + StoreDir: storeDir, + }, + Web: config.WebConfig{DistDir: filepath.Join(root, "web-dist")}, + Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"}, + }, + Logger: logger, + Documents: docService, + Repository: docRepo, + ContentStore: contentStore, + Hub: realtime.NewHub(logger), + SyncService: sync.NewService(syncRepo, docService, contentStore, sourceDir, logger), + SyncRepo: syncRepo, + Auth: authService, + }) + if err != nil { + t.Fatalf("create http server: %v", err) + } + + return &apiTestServer{ + handler: handler, + auth: authService, + docs: docService, + userID: user.ID, + root: root, + } +} + +func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string { + t.Helper() + + created, err := s.auth.CreateAPIKey(context.Background(), s.userID, "test token", scopes, nil) + if err != nil { + t.Fatalf("create api token: %v", err) + } + return created.Token +} + +func TestAPIUploadStoresAndServesAttachment(t *testing.T) { + server := newAPITestServer(t) + token := server.token(t, auth.ScopeDocsWrite) + + body, contentType := multipartBody(t, "file", `unsafe"name.txt`, []byte("plain attachment\n")) + request := httptest.NewRequest(http.MethodPost, "/api/uploads", body) + request.Header.Set("Content-Type", contentType) + request.Header.Set("Authorization", "Bearer "+token) + recorder := httptest.NewRecorder() + + server.handler.ServeHTTP(recorder, request) + + response := recorder.Result() + if response.StatusCode != http.StatusCreated { + t.Fatalf("upload status = %d, want %d; body=%s", response.StatusCode, http.StatusCreated, recorder.Body.String()) + } + + var payload struct { + Hash string `json:"hash"` + ContentType string `json:"contentType"` + AttachmentURL string `json:"attachmentUrl"` + } + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + t.Fatalf("decode upload response: %v", err) + } + if payload.Hash == "" { + t.Fatal("expected upload response hash") + } + if payload.ContentType != "text/plain; charset=utf-8" { + t.Fatalf("contentType = %q, want text/plain; charset=utf-8", payload.ContentType) + } + if payload.AttachmentURL != "/attachments/"+payload.Hash { + t.Fatalf("attachmentUrl = %q, want /attachments/%s", payload.AttachmentURL, payload.Hash) + } + + download := httptest.NewRequest(http.MethodGet, payload.AttachmentURL, nil) + downloadRecorder := httptest.NewRecorder() + server.handler.ServeHTTP(downloadRecorder, download) + + downloadResponse := downloadRecorder.Result() + if downloadResponse.StatusCode != http.StatusOK { + t.Fatalf("download status = %d, want %d", downloadResponse.StatusCode, http.StatusOK) + } + if got := downloadResponse.Header.Get("Content-Type"); got != payload.ContentType { + t.Fatalf("download content-type = %q, want %q", got, payload.ContentType) + } + if got := downloadResponse.Header.Get("Content-Disposition"); got != `inline; filename="unsafename.txt"` { + t.Fatalf("content-disposition = %q, want sanitized filename", got) + } + if downloadRecorder.Body.String() != "plain attachment\n" { + t.Fatalf("download body = %q", downloadRecorder.Body.String()) + } +} + +func TestAPIUploadRejectsTokenWithoutDocsWriteScope(t *testing.T) { + server := newAPITestServer(t) + token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite) + + body, contentType := multipartBody(t, "file", "note.txt", []byte("plain attachment\n")) + request := httptest.NewRequest(http.MethodPost, "/api/uploads", body) + request.Header.Set("Content-Type", contentType) + request.Header.Set("Authorization", "Bearer "+token) + recorder := httptest.NewRecorder() + + server.handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden) + } +} + +func TestAPISyncInitAndContentFetchUseBearerScopes(t *testing.T) { + server := newAPITestServer(t) + token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite) + + request := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"}) + request.Header.Set("Authorization", "Bearer "+token) + recorder := httptest.NewRecorder() + + server.handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("sync init status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + + var payload struct { + SnapshotID string `json:"snapshotId"` + ServerSnapshot []sync.FileEntry `json:"serverSnapshot"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode sync init response: %v", err) + } + if payload.SnapshotID == "" { + t.Fatal("expected snapshot id") + } + if len(payload.ServerSnapshot) != 1 || payload.ServerSnapshot[0].Path != "hello.md" { + t.Fatalf("server snapshot = %#v, want hello.md entry", payload.ServerSnapshot) + } + + fetch := httptest.NewRequest(http.MethodGet, "/api/content/"+payload.ServerSnapshot[0].Hash, nil) + fetch.Header.Set("Authorization", "Bearer "+token) + fetchRecorder := httptest.NewRecorder() + server.handler.ServeHTTP(fetchRecorder, fetch) + + if fetchRecorder.Code != http.StatusOK { + t.Fatalf("content fetch status = %d, want %d", fetchRecorder.Code, http.StatusOK) + } + if fetchRecorder.Body.String() != "# Hello\n\nInitial" { + t.Fatalf("content fetch body = %q", fetchRecorder.Body.String()) + } +} + +func TestAPISyncInitRejectsTokenWithoutSyncWriteScope(t *testing.T) { + server := newAPITestServer(t) + token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite) + + request := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"}) + request.Header.Set("Authorization", "Bearer "+token) + recorder := httptest.NewRecorder() + + server.handler.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusForbidden) + } +} + +func TestAPISyncDeltaReturnsServerChanges(t *testing.T) { + server := newAPITestServer(t) + token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite) + + initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"}) + initRequest.Header.Set("Authorization", "Bearer "+token) + initRecorder := httptest.NewRecorder() + server.handler.ServeHTTP(initRecorder, initRequest) + if initRecorder.Code != http.StatusOK { + t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String()) + } + + var initPayload struct { + SnapshotID string `json:"snapshotId"` + } + if err := json.Unmarshal(initRecorder.Body.Bytes(), &initPayload); err != nil { + t.Fatalf("decode sync init response: %v", err) + } + + if err := os.WriteFile(filepath.Join(server.root, "content", "hello.md"), []byte("# Hello\n\nServer update"), 0o644); err != nil { + t.Fatalf("write server update: %v", err) + } + + deltaRequest := jsonRequest(http.MethodPost, "/api/sync/delta", map[string]any{ + "snapshotId": initPayload.SnapshotID, + "clientDelta": []sync.Change{}, + }) + deltaRequest.Header.Set("Authorization", "Bearer "+token) + deltaRecorder := httptest.NewRecorder() + server.handler.ServeHTTP(deltaRecorder, deltaRequest) + + if deltaRecorder.Code != http.StatusOK { + t.Fatalf("sync delta status = %d, want %d; body=%s", deltaRecorder.Code, http.StatusOK, deltaRecorder.Body.String()) + } + + var deltaPayload struct { + ServerDelta []sync.Change `json:"serverDelta"` + Conflicts []sync.Conflict `json:"conflicts"` + } + if err := json.Unmarshal(deltaRecorder.Body.Bytes(), &deltaPayload); err != nil { + t.Fatalf("decode sync delta response: %v", err) + } + if len(deltaPayload.ServerDelta) != 1 { + t.Fatalf("serverDelta = %#v, want one update", deltaPayload.ServerDelta) + } + change := deltaPayload.ServerDelta[0] + if change.Type != sync.ChangeUpdate || change.Path != "hello.md" || change.Hash == "" { + t.Fatalf("server delta change = %#v, want update for hello.md with hash", change) + } + if len(deltaPayload.Conflicts) != 0 { + t.Fatalf("conflicts = %#v, want none", deltaPayload.Conflicts) + } +} + +func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) { + server := newAPITestServer(t) + token := server.token(t, auth.ScopeDocsWrite) + + documents := httptest.NewRecorder() + server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil)) + if documents.Code != http.StatusOK { + t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK) + } + + var list struct { + Documents []struct { + Path string `json:"path"` + Hash string `json:"hash"` + } `json:"documents"` + } + if err := json.Unmarshal(documents.Body.Bytes(), &list); err != nil { + t.Fatalf("decode documents: %v", err) + } + if len(list.Documents) != 1 { + t.Fatalf("documents = %#v, want one document", list.Documents) + } + + if err := os.WriteFile(filepath.Join(server.root, "content", "hello.md"), []byte("# Hello\n\nServer edit"), 0o644); err != nil { + t.Fatalf("write server edit: %v", err) + } + + save := jsonRequest(http.MethodPost, "/api/documents/hello", map[string]string{ + "content": "# Hello\n\nClient edit", + "baseHash": list.Documents[0].Hash, + }) + save.Header.Set("Authorization", "Bearer "+token) + saveRecorder := httptest.NewRecorder() + + server.handler.ServeHTTP(saveRecorder, save) + + if saveRecorder.Code != http.StatusConflict { + t.Fatalf("save status = %d, want %d; body=%s", saveRecorder.Code, http.StatusConflict, saveRecorder.Body.String()) + } + + var conflict struct { + Status string `json:"status"` + Path string `json:"path"` + BaseHash string `json:"baseHash"` + CurrentHash string `json:"currentHash"` + CurrentContent string `json:"currentContent"` + } + if err := json.Unmarshal(saveRecorder.Body.Bytes(), &conflict); err != nil { + t.Fatalf("decode conflict response: %v", err) + } + if conflict.Status != "conflict" || conflict.Path != "hello.md" { + t.Fatalf("conflict payload = %#v, want conflict for hello.md", conflict) + } + if conflict.BaseHash != list.Documents[0].Hash { + t.Fatalf("baseHash = %q, want %q", conflict.BaseHash, list.Documents[0].Hash) + } + if conflict.CurrentHash == "" || conflict.CurrentHash == conflict.BaseHash { + t.Fatalf("currentHash = %q, baseHash = %q", conflict.CurrentHash, conflict.BaseHash) + } + if conflict.CurrentContent != "# Hello\n\nServer edit" { + t.Fatalf("currentContent = %q", conflict.CurrentContent) + } +} + +func multipartBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) { + t.Helper() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile(fieldName, filename) + if err != nil { + t.Fatalf("create multipart file: %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("write multipart file: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + return body, writer.FormDataContentType() +} + +func jsonRequest(method, path string, payload any) *http.Request { + body, err := json.Marshal(payload) + if err != nil { + panic(err) + } + request := httptest.NewRequest(method, path, bytes.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + return request +} diff --git a/apps/server/internal/httpserver/collab_handlers.go b/apps/server/internal/httpserver/collab_handlers.go new file mode 100644 index 0000000..0766c3b --- /dev/null +++ b/apps/server/internal/httpserver/collab_handlers.go @@ -0,0 +1,262 @@ +package httpserver + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/tim/cairnquire/apps/server/internal/collaboration" +) + +// Comments + +func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) { + documentID := r.URL.Query().Get("document_id") + if documentID == "" { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document_id is required"}) + return + } + + anchorHash := r.URL.Query().Get("anchor_hash") + var comments []collaboration.Comment + var err error + if anchorHash != "" { + comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash) + } else { + comments, err = s.collaboration.ListComments(r.Context(), documentID) + } + if err != nil { + s.logger.Warn("list comments", "error", err) + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"comments": comments}) +} + +func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) { + principal, ok := requirePrincipal(r) + if !ok { + writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + + var input collaboration.CreateCommentInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + comment, err := s.collaboration.CreateComment(r.Context(), principal, input) + if err != nil { + s.logger.Warn("create comment", "error", err) + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSONWithStatus(w, http.StatusCreated, comment) +} + +func (s *Server) handleResolveComment(w http.ResponseWriter, r *http.Request) { + principal, ok := requirePrincipal(r) + if !ok { + writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + + commentID := chi.URLParam(r, "id") + if err := s.collaboration.ResolveComment(r.Context(), principal, commentID); err != nil { + s.logger.Warn("resolve comment", "error", err) + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"}) +} + +// Notifications + +func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) { + principal, ok := requirePrincipal(r) + if !ok { + writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + + 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 + } + + notifications, err := s.collaboration.ListNotifications(r.Context(), principal, unreadOnly, limit) + if err != nil { + s.logger.Warn("list notifications", "error", err) + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"notifications": notifications}) +} + +func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) { + principal, ok := requirePrincipal(r) + if !ok { + writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + + notificationID := chi.URLParam(r, "id") + if err := s.collaboration.MarkNotificationRead(r.Context(), principal, notificationID); err != nil { + s.logger.Warn("mark notification read", "error", err) + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) { + principal, ok := requirePrincipal(r) + if !ok { + writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + + if err := s.collaboration.MarkAllNotificationsRead(r.Context(), principal); err != nil { + s.logger.Warn("mark all notifications read", "error", err) + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +func (s *Server) handleNotificationBell(w http.ResponseWriter, r *http.Request) { + principal, ok := requirePrincipal(r) + if !ok { + writeJSON(w, http.StatusOK, collaboration.NotificationBell{UnreadCount: 0, Items: []collaboration.Notification{}}) + return + } + + bell, err := s.collaboration.GetBellStatus(r.Context(), principal) + if err != nil { + s.logger.Warn("notification bell", "error", err) + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, bell) +} + +// Watchers + +func (s *Server) handleWatchDocument(w http.ResponseWriter, r *http.Request) { + principal, ok := requirePrincipal(r) + if !ok { + writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + + var req struct { + DocumentID string `json:"documentId"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + if err := s.collaboration.WatchDocument(r.Context(), principal, req.DocumentID); err != nil { + s.logger.Warn("watch document", "error", err) + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "watching"}) +} + +func (s *Server) handleUnwatchDocument(w http.ResponseWriter, r *http.Request) { + principal, ok := requirePrincipal(r) + if !ok { + writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + + var req struct { + DocumentID string `json:"documentId"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + if err := s.collaboration.UnwatchDocument(r.Context(), principal, req.DocumentID); err != nil { + s.logger.Warn("unwatch document", "error", err) + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "unwatched"}) +} + +func (s *Server) handleWatchFolder(w http.ResponseWriter, r *http.Request) { + principal, ok := requirePrincipal(r) + if !ok { + writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + + var req struct { + FolderPath string `json:"folderPath"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + if err := s.collaboration.WatchFolder(r.Context(), principal, req.FolderPath); err != nil { + s.logger.Warn("watch folder", "error", err) + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "watching"}) +} + +func (s *Server) handleUnwatchFolder(w http.ResponseWriter, r *http.Request) { + principal, ok := requirePrincipal(r) + if !ok { + writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"}) + return + } + + var req struct { + FolderPath string `json:"folderPath"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + if err := s.collaboration.UnwatchFolder(r.Context(), principal, req.FolderPath); err != nil { + s.logger.Warn("unwatch folder", "error", err) + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "unwatched"}) +} + +func (s *Server) handleIsWatching(w http.ResponseWriter, r *http.Request) { + principal, ok := requirePrincipal(r) + if !ok { + writeJSON(w, http.StatusOK, map[string]bool{"watching": false}) + return + } + + documentID := r.URL.Query().Get("document_id") + watching, err := s.collaboration.IsWatching(r.Context(), principal, documentID) + if err != nil { + s.logger.Warn("is watching", "error", err) + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"watching": watching}) +} + +// Document page with comments + +func (s *Server) renderDocumentPageWithComments(w http.ResponseWriter, r *http.Request, pagePath string) { + // This is a helper that could be used to inject comments into the template + // For now, comments are fetched client-side via JS + s.renderDocumentPage(w, r, pagePath) +} diff --git a/apps/server/internal/httpserver/middleware.go b/apps/server/internal/httpserver/middleware.go index a3f3cb5..e32878a 100644 --- a/apps/server/internal/httpserver/middleware.go +++ b/apps/server/internal/httpserver/middleware.go @@ -127,6 +127,12 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) { return auth.ScopeSyncRead, true case strings.HasPrefix(path, "/api/content/"): return auth.ScopeSyncRead, true + case strings.HasPrefix(path, "/api/comments"): + return auth.ScopeDocsRead, true + case strings.HasPrefix(path, "/api/notifications"): + return auth.ScopeDocsRead, true + case strings.HasPrefix(path, "/api/watch"): + return auth.ScopeDocsRead, true case strings.HasSuffix(path, "/edit"): return auth.ScopeDocsWrite, true default: diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index d601566..7973014 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -13,6 +13,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "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/docs" "github.com/tim/cairnquire/apps/server/internal/realtime" @@ -24,30 +25,32 @@ import ( var assets embed.FS type Dependencies struct { - Config config.Config - Logger *slog.Logger - Documents *docs.Service - Repository *docs.Repository - ContentStore *store.ContentStore - Hub *realtime.Hub - SyncService *sync.Service - SyncRepo *sync.Repository - Auth *auth.Service + Config config.Config + Logger *slog.Logger + Documents *docs.Service + Repository *docs.Repository + ContentStore *store.ContentStore + Hub *realtime.Hub + SyncService *sync.Service + SyncRepo *sync.Repository + Auth *auth.Service + Collaboration *collaboration.Service } type Server struct { - config config.Config - logger *slog.Logger - documents *docs.Service - repository *docs.Repository - contentStore *store.ContentStore - hub *realtime.Hub - syncService *sync.Service - syncRepo *sync.Repository - auth *auth.Service - authLimiter *rateLimiter - templates *template.Template - webEnabled bool + config config.Config + logger *slog.Logger + documents *docs.Service + repository *docs.Repository + contentStore *store.ContentStore + hub *realtime.Hub + syncService *sync.Service + syncRepo *sync.Repository + auth *auth.Service + collaboration *collaboration.Service + authLimiter *rateLimiter + templates *template.Template + webEnabled bool } func New(deps Dependencies) (http.Handler, error) { @@ -64,17 +67,18 @@ func New(deps Dependencies) (http.Handler, error) { } server := &Server{ - config: deps.Config, - logger: deps.Logger, - documents: deps.Documents, - repository: deps.Repository, - contentStore: deps.ContentStore, - hub: deps.Hub, - syncService: deps.SyncService, - syncRepo: deps.SyncRepo, - auth: deps.Auth, - authLimiter: newRateLimiter(5, time.Minute), - templates: templates, + config: deps.Config, + logger: deps.Logger, + documents: deps.Documents, + repository: deps.Repository, + contentStore: deps.ContentStore, + hub: deps.Hub, + syncService: deps.SyncService, + syncRepo: deps.SyncRepo, + auth: deps.Auth, + collaboration: deps.Collaboration, + authLimiter: newRateLimiter(5, time.Minute), + templates: templates, } if _, err := os.Stat(deps.Config.Web.DistDir); err == nil { @@ -129,6 +133,20 @@ func New(deps Dependencies) (http.Handler, error) { router.Post("/api/uploads", server.handleUpload) router.Get("/attachments/{hash}", server.handleAttachment) + // Collaboration endpoints + router.Get("/api/comments", server.handleListComments) + router.Post("/api/comments", server.handleCreateComment) + router.Post("/api/comments/{id}/resolve", server.handleResolveComment) + router.Get("/api/notifications", server.handleListNotifications) + router.Post("/api/notifications/{id}/read", server.handleMarkNotificationRead) + router.Post("/api/notifications/read-all", server.handleMarkAllNotificationsRead) + router.Get("/api/notifications/bell", server.handleNotificationBell) + router.Post("/api/watch/document", server.handleWatchDocument) + router.Post("/api/watch/folder", server.handleWatchFolder) + router.Delete("/api/watch/document", server.handleUnwatchDocument) + router.Delete("/api/watch/folder", server.handleUnwatchFolder) + router.Get("/api/watch/status", server.handleIsWatching) + // Sync protocol endpoints router.Post("/api/sync/init", server.handleSyncInit) router.Post("/api/sync/delta", server.handleSyncDelta) diff --git a/apps/server/internal/httpserver/static/auth.js b/apps/server/internal/httpserver/static/auth.js index 75e0f39..c82b980 100644 --- a/apps/server/internal/httpserver/static/auth.js +++ b/apps/server/internal/httpserver/static/auth.js @@ -1,4 +1,56 @@ (() => { + // Tab switching + document.querySelectorAll('.auth-tab-list').forEach(tabList => { + const tabs = tabList.querySelectorAll('[data-tab]'); + const panels = tabList.closest('.auth-tabs').querySelectorAll('[data-tab-panel]'); + + tabs.forEach(tab => { + tab.addEventListener('click', () => { + const target = tab.dataset.tab; + + tabs.forEach(t => { + t.classList.remove('is-active'); + t.setAttribute('aria-selected', 'false'); + }); + tab.classList.add('is-active'); + tab.setAttribute('aria-selected', 'true'); + + panels.forEach(p => { + if (p.dataset.tabPanel === target) { + p.classList.add('is-active'); + p.hidden = false; + } else { + p.classList.remove('is-active'); + p.hidden = true; + } + }); + }); + }); + }); + + // Register toggle + const registerToggle = document.querySelector('[data-register-toggle]'); + const registerPanel = document.querySelector('[data-register-panel]'); + const loginSection = document.querySelector('[data-login-section]'); + const registerCancel = document.querySelector('[data-register-cancel]'); + + if (registerToggle && registerPanel && loginSection) { + registerToggle.addEventListener('click', () => { + loginSection.hidden = true; + registerPanel.hidden = false; + }); + } + + if (registerCancel && registerPanel && loginSection) { + registerCancel.addEventListener('click', () => { + registerPanel.hidden = true; + loginSection.hidden = false; + // Clear any registration form status messages + document.querySelector('[data-passkey-register-status]').textContent = ''; + document.querySelector('[data-register-status]').textContent = ''; + }); + } + const jsonHeaders = { "Content-Type": "application/json" }; const postJSON = async (url, body, options = {}) => { @@ -95,35 +147,76 @@ } }); + const webAuthnErrorMessage = (error) => { + console.error("WebAuthn error:", error.name, error.message, error); + switch (error.name) { + case "NotAllowedError": + return "Passkey creation was cancelled or no authenticator is available. If you dismissed a prompt, try again. If no prompt appeared, your device may not support passkeys."; + case "NotSupportedError": + return "This browser or device does not support passkeys."; + case "SecurityError": + return "Passkeys require a secure context. Use https:// or localhost."; + case "InvalidStateError": + return "A passkey may already exist for this account."; + default: + return error.message || "Passkey failed. Check the console for details."; + } + }; + + const checkPasskeySupport = async () => { + if (!window.PublicKeyCredential) { + return { supported: false, reason: "Passkeys are not available in this browser." }; + } + try { + if (PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) { + const available = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + if (!available) { + return { supported: true, reason: "No platform authenticator found (e.g., Touch ID, Windows Hello). You can still use a security key if you have one." }; + } + } + return { supported: true }; + } catch { + return { supported: true }; + } + }; + document.querySelector("[data-passkey-register]")?.addEventListener("submit", async (event) => { event.preventDefault(); - if (!window.PublicKeyCredential) { - setStatus("[data-passkey-register-status]", "Passkeys are not available in this browser.", true); + const support = await checkPasskeySupport(); + if (!support.supported) { + setStatus("[data-passkey-register-status]", support.reason, true); return; } + if (support.reason) { + console.warn("Passkey support:", support.reason); + } try { const begin = await postJSON("/api/auth/passkeys/register/begin", formJSON(event.currentTarget)); const credential = await navigator.credentials.create(normalizeCreateOptions(begin.options)); await postJSON(`/api/auth/passkeys/register/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential)); setStatus("[data-passkey-register-status]", "Passkey created. Sign in with your passkey."); } catch (error) { - setStatus("[data-passkey-register-status]", error.message, true); + setStatus("[data-passkey-register-status]", webAuthnErrorMessage(error), true); } }); document.querySelector("[data-passkey-login]")?.addEventListener("submit", async (event) => { event.preventDefault(); - if (!window.PublicKeyCredential) { - setStatus("[data-passkey-login-status]", "Passkeys are not available in this browser.", true); + const support = await checkPasskeySupport(); + if (!support.supported) { + setStatus("[data-passkey-login-status]", support.reason, true); return; } + if (support.reason) { + console.warn("Passkey support:", support.reason); + } try { const begin = await postJSON("/api/auth/passkeys/login/begin", formJSON(event.currentTarget)); const credential = await navigator.credentials.get(normalizeRequestOptions(begin.options)); await postJSON(`/api/auth/passkeys/login/finish?challengeId=${encodeURIComponent(begin.challengeId)}`, credentialToJSON(credential)); window.location.href = authNext(); } catch (error) { - setStatus("[data-passkey-login-status]", error.message, true); + setStatus("[data-passkey-login-status]", webAuthnErrorMessage(error), true); } }); diff --git a/apps/server/internal/httpserver/static/comments.js b/apps/server/internal/httpserver/static/comments.js new file mode 100644 index 0000000..e8cfd37 --- /dev/null +++ b/apps/server/internal/httpserver/static/comments.js @@ -0,0 +1,123 @@ +(function () { + 'use strict'; + + const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath; + const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash; + const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null; + + if (!documentId) return; + + // Hash a paragraph's text content for anchoring + function hashParagraph(el) { + const text = (el.textContent || '').trim(); + if (!text) return null; + // Simple hash: first 16 chars of base64 of char codes (deterministic) + let hash = 0; + for (let i = 0; i < text.length; i++) { + const char = text.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return 'h' + Math.abs(hash).toString(36); + } + + function addCommentButton(el, hash) { + const btn = document.createElement('button'); + btn.className = 'comment-anchor-btn'; + btn.title = 'Add comment'; + btn.innerHTML = '+'; + btn.addEventListener('click', () => promptComment(el, hash)); + el.appendChild(btn); + } + + async function promptComment(el, anchorHash) { + const text = window.prompt('Comment on this paragraph:'); + if (!text || !text.trim()) return; + + try { + const res = await fetch('/api/comments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + documentId, + versionHash: documentHash, + content: text.trim(), + anchorHash, + }), + }); + if (!res.ok) throw new Error(await res.text()); + // Refresh comments display + await loadCommentsForAnchor(el, anchorHash); + } catch (err) { + window.alert('Failed to post comment: ' + err.message); + } + } + + async function loadCommentsForAnchor(el, anchorHash) { + 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 || []); + } catch (err) { + console.error('load comments', err); + } + } + + function renderComments(el, comments) { + // Remove existing comment list + const existing = el.querySelector('.comment-list'); + if (existing) existing.remove(); + + if (!comments.length) return; + + const list = document.createElement('div'); + list.className = 'comment-list'; + comments.forEach(c => { + const item = document.createElement('div'); + item.className = 'comment-item'; + item.innerHTML = ` +
+ ${escapeHtml(c.authorName || 'Anonymous')} + +
+
${escapeHtml(c.content)}
+ `; + list.appendChild(item); + }); + el.appendChild(list); + } + + function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + function formatDate(iso) { + const d = new Date(iso); + return d.toLocaleString(); + } + + // Initialize: hash all paragraphs in markdown body + 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; + el.classList.add('commentable'); + addCommentButton(el, hash); + loadCommentsForAnchor(el, hash); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/apps/server/internal/httpserver/static/notification-bell.js b/apps/server/internal/httpserver/static/notification-bell.js new file mode 100644 index 0000000..1fb860a --- /dev/null +++ b/apps/server/internal/httpserver/static/notification-bell.js @@ -0,0 +1,123 @@ +(function () { + 'use strict'; + + const bell = document.querySelector('[data-notification-bell]'); + if (!bell) return; + + const countEl = bell.querySelector('[data-unread-count]'); + const dropdown = bell.querySelector('[data-notification-dropdown]'); + let isOpen = false; + + bell.addEventListener('click', () => { + isOpen = !isOpen; + dropdown.hidden = !isOpen; + if (isOpen) loadNotifications(); + }); + + document.addEventListener('click', (e) => { + if (!bell.contains(e.target)) { + isOpen = false; + dropdown.hidden = true; + } + }); + + async function updateBell() { + try { + const res = await fetch('/api/notifications/bell'); + if (!res.ok) return; + const data = await res.json(); + const count = data.unreadCount || 0; + countEl.textContent = count > 99 ? '99+' : String(count); + countEl.hidden = count === 0; + bell.classList.toggle('has-unread', count > 0); + } catch (err) { + console.error('bell update', err); + } + } + + async function loadNotifications() { + try { + const res = await fetch('/api/notifications?limit=20'); + if (!res.ok) return; + const data = await res.json(); + renderNotifications(data.notifications || []); + } catch (err) { + console.error('load notifications', err); + } + } + + function renderNotifications(notifications) { + const list = dropdown.querySelector('ul'); + if (!list) return; + list.innerHTML = ''; + + if (!notifications.length) { + list.innerHTML = '
  • No notifications
  • '; + return; + } + + notifications.forEach(n => { + const li = document.createElement('li'); + li.className = n.readAt ? 'notification-item is-read' : 'notification-item'; + li.innerHTML = ` +
    ${escapeHtml(n.message)}
    + + `; + if (!n.readAt) { + li.addEventListener('click', () => markRead(n.id)); + } + 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); + } + + async function markRead(id) { + try { + await fetch(`/api/notifications/${id}/read`, { method: 'POST' }); + updateBell(); + loadNotifications(); + } catch (err) { + console.error('mark read', err); + } + } + + async function markAllRead() { + try { + await fetch('/api/notifications/read-all', { method: 'POST' }); + updateBell(); + loadNotifications(); + } catch (err) { + console.error('mark all read', err); + } + } + + function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + function formatDate(iso) { + const d = new Date(iso); + return d.toLocaleString(); + } + + // Listen for WebSocket notification events + if (window.__cairnquireRealtime) { + window.__cairnquireRealtime.on('notification', () => { + updateBell(); + }); + } + + // Poll every 60s as fallback + setInterval(updateBell, 60000); + updateBell(); +})(); diff --git a/apps/server/internal/httpserver/static/realtime.js b/apps/server/internal/httpserver/static/realtime.js index fc37590..6cb4d87 100644 --- a/apps/server/internal/httpserver/static/realtime.js +++ b/apps/server/internal/httpserver/static/realtime.js @@ -1,4 +1,23 @@ (function () { + const realtimeCallbacks = { + notification: [], + }; + + window.__cairnquireRealtime = { + on: function (event, cb) { + if (realtimeCallbacks[event]) { + realtimeCallbacks[event].push(cb); + } + }, + off: function (event, cb) { + if (realtimeCallbacks[event]) { + realtimeCallbacks[event] = realtimeCallbacks[event].filter(function (c) { + return c !== cb; + }); + } + }, + }; + const notice = document.querySelector("[data-version-notice]"); const reload = document.querySelector("[data-version-reload]"); const offlineNotice = document.querySelector("[data-offline-notice]"); @@ -300,6 +319,13 @@ return; } + if (payload.type === "notification") { + realtimeCallbacks.notification.forEach(function (cb) { + cb(payload.data); + }); + return; + } + if (payload.type !== "document_version" || !payload.data) { return; } diff --git a/apps/server/internal/httpserver/static/site.css b/apps/server/internal/httpserver/static/site.css index ff4634a..13a1c6f 100644 --- a/apps/server/internal/httpserver/static/site.css +++ b/apps/server/internal/httpserver/static/site.css @@ -106,45 +106,61 @@ code { .site-nav { display: flex; - gap: 1.25rem; + gap: 0.5rem; } .site-nav a { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 50%; + color: inherit; text-decoration: none; - font-size: 0.95rem; + transition: background 0.15s; +} + +.site-nav a:hover { + background: rgba(0, 0, 0, 0.06); +} + +.site-nav a svg { + display: block; } .site-search { + position: relative; display: flex; align-items: center; - gap: 0.5rem; flex: 1; max-width: 320px; margin: 0 1rem; } +.site-search__icon { + position: absolute; + left: 0.6rem; + z-index: 1; + color: var(--muted); + pointer-events: none; +} + .site-search input { width: 100%; - padding: 0.45rem 0.75rem; + padding: 0.4rem 0.75rem 0.4rem 2rem; border: 1px solid var(--border); - border-radius: 0; - background: var(--panel-strong); + border-radius: 999px; + background: rgba(255, 255, 255, 0.6); font: inherit; - font-size: 0.9rem; + font-size: 0.825rem; + transition: border-color 0.15s, background 0.15s; } .site-search input:focus { - outline: 2px solid var(--accent-soft); + outline: none; border-color: var(--accent); -} - -.site-search button { - padding: 0.45rem 0.6rem; - border: 1px solid var(--border); - border-radius: var(--radius-sm); background: var(--panel-strong); - cursor: pointer; - font-size: 0.9rem; } .site-main { @@ -250,11 +266,30 @@ code { cursor: pointer; } +.btn-primary, .auth-form button[type="submit"], .account-header button { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); background: var(--accent); color: white; + padding: 0.85rem 1.75rem; + min-height: 3rem; + font-size: 1.05rem; + font-weight: 600; + box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent); + transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease; +} + +.btn-primary:hover, +.auth-form button[type="submit"]:hover { + background: color-mix(in srgb, var(--accent) 85%, black); + box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 40%, transparent); +} + +.btn-primary:active, +.auth-form button[type="submit"]:active { + transform: translateY(1px); + box-shadow: 0 2px 6px color-mix(in srgb, var(--accent) 30%, transparent); } .auth-details { @@ -289,6 +324,123 @@ code { color: var(--muted); } +/* Auth tabs */ +.auth-tabs { + margin-bottom: 1.5rem; +} + +.auth-tab-list { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + margin-bottom: 1rem; +} + +.auth-tab { + padding: 0.5rem 1rem; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 0.9rem; + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease; +} + +.auth-tab:hover { + color: var(--text); +} + +.auth-tab.is-active { + color: var(--accent); + border-bottom-color: var(--accent); + font-weight: 600; +} + +.auth-tab-panel { + display: none; +} + +.auth-tab-panel.is-active { + display: block; +} + +/* Register CTA */ +.auth-register-cta { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); + text-align: center; +} + +.btn-cta { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + width: 100%; + padding: 1rem 2rem; + min-height: 3.5rem; + border: 2px solid var(--accent); + border-radius: var(--radius-sm); + background: var(--accent); + color: white; + font: inherit; + font-size: 1.15rem; + font-weight: 700; + letter-spacing: 0.01em; + cursor: pointer; + box-shadow: 0 6px 20px color-mix(in srgb, var(--accent) 35%, transparent); + transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease; +} + +.btn-cta:hover { + background: color-mix(in srgb, var(--accent) 85%, black); + box-shadow: 0 8px 24px color-mix(in srgb, var(--accent) 45%, transparent); +} + +.btn-cta:active { + transform: translateY(2px); + box-shadow: 0 3px 10px color-mix(in srgb, var(--accent) 35%, transparent); +} + +.auth-register-panel { + padding: 0; + border: none; + background: transparent; +} + +.auth-register-cancel { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--border); + text-align: center; +} + +.btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.65rem 1.25rem; + min-height: 2.6rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted); + font: inherit; + font-size: 0.95rem; + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; +} + +.btn-secondary:hover { + color: var(--text); + border-color: var(--muted); + background: var(--panel); +} + .account-header { display: flex; align-items: center; @@ -1595,3 +1747,74 @@ code { background: oklch(0.705 0.165 55 / 0.07); } } + +/* Comments */ +.commentable { + position: relative; +} + +.commentable:hover { + background: var(--accent-soft); +} + +.comment-anchor-btn { + position: absolute; + right: -1.5rem; + top: 0.25rem; + width: 1.2rem; + height: 1.2rem; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--panel-strong); + color: var(--accent); + font-size: 0.8rem; + font-weight: 700; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease; +} + +.commentable:hover .comment-anchor-btn { + opacity: 1; +} + +.comment-list { + margin-top: 0.5rem; + padding: 0.5rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel); +} + +.comment-item { + padding: 0.5rem 0; + border-bottom: 1px solid var(--border); +} + +.comment-item:last-child { + border-bottom: 0; +} + +.comment-meta { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; + font-size: 0.8rem; + color: var(--muted); +} + +.comment-meta strong { + color: var(--text); +} + +.comment-body { + font-size: 0.9rem; + line-height: 1.5; +} + + diff --git a/apps/server/internal/httpserver/templates/base.gohtml b/apps/server/internal/httpserver/templates/base.gohtml index 5bde2b5..68176e1 100644 --- a/apps/server/internal/httpserver/templates/base.gohtml +++ b/apps/server/internal/httpserver/templates/base.gohtml @@ -22,14 +22,20 @@ Cairnquire - @@ -80,6 +86,7 @@ + diff --git a/apps/server/internal/httpserver/templates/login.gohtml b/apps/server/internal/httpserver/templates/login.gohtml index d4c6363..1bc8474 100644 --- a/apps/server/internal/httpserver/templates/login.gohtml +++ b/apps/server/internal/httpserver/templates/login.gohtml @@ -6,71 +6,94 @@

    Cairnquire

    -

    Sign in

    +

    Log In

    -
    -
    -

    Password

    - - - -

    -
    + + +
    {{ end }} diff --git a/cairnquire b/cairnquire new file mode 100755 index 0000000..5c4e320 Binary files /dev/null and b/cairnquire differ diff --git a/docker-compose.yml b/docker-compose.yml index 095a445..0ac286f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,9 +11,14 @@ services: CAIRNQUIRE_CONTENT_SOURCE_DIR: "/workspace/content" CAIRNQUIRE_CONTENT_STORE_DIR: "/workspace/data/files" CAIRNQUIRE_WEB_DIST_DIR: "/workspace/web-dist" + CAIRNQUIRE_PUBLIC_ORIGIN: "https://cairnquire.bendtstudio.com" volumes: - - ./content:/workspace/content:ro + - ./content:/workspace/content - ./data:/workspace/data + environment: + CAIRNQUIRE_EMAIL_SMTP_HOST: mailpit + CAIRNQUIRE_EMAIL_SMTP_PORT: "1025" + CAIRNQUIRE_EMAIL_FROM: "notifications@cairnquire.local" healthcheck: test: ["CMD", "curl", "--fail", "http://127.0.0.1:8080/health"] interval: 10s @@ -21,3 +26,10 @@ services: retries: 5 start_period: 5s + mailpit: + image: axllent/mailpit:latest + ports: + - "8025:8025" + environment: + MP_UI_BIND_ADDR: "0.0.0.0:8025" + MP_SMTP_BIND_ADDR: "0.0.0.0:1025" diff --git a/go.work.sum b/go.work.sum index 9eaabee..71630e0 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,25 +1,7 @@ -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs= -github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA= -github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=