server and web app
This commit is contained in:
304
apps/server/internal/collaboration/repository.go
Normal file
304
apps/server/internal/collaboration/repository.go
Normal file
@@ -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()
|
||||
}
|
||||
275
apps/server/internal/collaboration/service.go
Normal file
275
apps/server/internal/collaboration/service.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
51
apps/server/internal/collaboration/types.go
Normal file
51
apps/server/internal/collaboration/types.go
Normal file
@@ -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"`
|
||||
}
|
||||
71
apps/server/internal/collaboration/util.go
Normal file
71
apps/server/internal/collaboration/util.go
Normal file
@@ -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:")
|
||||
}
|
||||
Reference in New Issue
Block a user