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() }