feat: Enhance real-time collaboration features with user awareness and document sharing

- Added user information (UserID, UserName, UserAvatar) to Client struct for presence tracking.
- Implemented failure handling in the broadcastMessage function to manage send failures and disconnect clients if necessary.
- Introduced document ownership and sharing capabilities:
  - Added OwnerID and Is_Public fields to Document model.
  - Created DocumentShare model for managing document sharing with permissions.
  - Implemented functions for creating, listing, and managing document shares in the Postgres store.
- Added user management functionality:
  - Created User model and associated functions for user management in the Postgres store.
  - Implemented session management with token hashing for security.
- Updated database schema with migrations for users, sessions, and document shares.
- Enhanced frontend Yjs integration with awareness event logging for user connections and disconnections.
This commit is contained in:
M1ngdaXie
2026-01-03 12:59:53 -08:00
parent 37d89b13b9
commit 7f5f32179b
21 changed files with 2064 additions and 232 deletions

View File

@@ -1,6 +1,7 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
@@ -10,11 +11,49 @@ import (
_ "github.com/lib/pq" // PostgreSQL driver
)
type Store struct{
db *sql.DB
// Store interface defines all database operations
type Store interface {
// Document operations
CreateDocument(name string, docType models.DocumentType) (*models.Document, error)
CreateDocumentWithOwner(name string, docType models.DocumentType, ownerID *uuid.UUID) (*models.Document, error) // ADD THIS
GetDocument(id uuid.UUID) (*models.Document, error)
ListDocuments() ([]models.Document, error)
ListUserDocuments(ctx context.Context, userID uuid.UUID) ([]models.Document, error) // ADD THIS
UpdateDocumentState(id uuid.UUID, state []byte) error
DeleteDocument(id uuid.UUID) error
// User operations
UpsertUser(ctx context.Context, provider, providerUserID, email, name string, avatarURL *string) (*models.User, error)
GetUserByID(ctx context.Context, userID uuid.UUID) (*models.User, error)
GetUserByEmail(ctx context.Context, email string) (*models.User, error)
// Session operations
CreateSession(ctx context.Context, userID uuid.UUID, sessionID uuid.UUID, token string, expiresAt time.Time, userAgent, ipAddress *string) (*models.Session, error)
GetSessionByToken(ctx context.Context, token string) (*models.Session, error)
DeleteSession(ctx context.Context, token string) error
CleanupExpiredSessions(ctx context.Context) error
// Share operations
CreateDocumentShare(ctx context.Context, documentID, userID uuid.UUID, permission string, createdBy *uuid.UUID) (*models.DocumentShare, error)
ListDocumentShares(ctx context.Context, documentID uuid.UUID) ([]models.DocumentShareWithUser, error)
DeleteDocumentShare(ctx context.Context, documentID, userID uuid.UUID) error
CanViewDocument(ctx context.Context, documentID, userID uuid.UUID) (bool, error)
CanEditDocument(ctx context.Context, documentID, userID uuid.UUID) (bool, error)
IsDocumentOwner(ctx context.Context, documentID, userID uuid.UUID) (bool, error)
GenerateShareToken(ctx context.Context, documentID uuid.UUID, permission string) (string, error)
ValidateShareToken(ctx context.Context, documentID uuid.UUID, token string) (bool, error)
RevokeShareToken(ctx context.Context, documentID uuid.UUID) error
GetShareToken(ctx context.Context, documentID uuid.UUID) (string, bool, error)
Close() error
}
func NewStore(databaseUrl string) (*Store, error) {
type PostgresStore struct {
db *sql.DB
}
func NewPostgresStore(databaseUrl string) (*PostgresStore, error) {
db, error := sql.Open("postgres", databaseUrl)
if error != nil {
return nil, error
@@ -25,14 +64,14 @@ func NewStore(databaseUrl string) (*Store, error) {
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
return &Store{db: db}, nil
return &PostgresStore{db: db}, nil
}
func (s *Store) Close() error {
func (s *PostgresStore) Close() error {
return s.db.Close()
}
func (s *Store) CreateDocument(name string, docType models.DocumentType) (*models.Document, error) {
func (s *PostgresStore) CreateDocument(name string, docType models.DocumentType) (*models.Document, error) {
doc := &models.Document{
ID: uuid.New(),
Name: name,
@@ -62,7 +101,7 @@ func (s *Store) CreateDocument(name string, docType models.DocumentType) (*model
}
// GetDocument retrieves a document by ID
func (s *Store) GetDocument(id uuid.UUID) (*models.Document, error) {
func (s *PostgresStore) GetDocument(id uuid.UUID) (*models.Document, error) {
doc := &models.Document{}
query := `
@@ -92,7 +131,7 @@ func (s *Store) CreateDocument(name string, docType models.DocumentType) (*model
// ListDocuments retrieves all documents
func (s *Store) ListDocuments() ([]models.Document, error) {
func (s *PostgresStore) ListDocuments() ([]models.Document, error) {
query := `
SELECT id, name, type, created_at, updated_at
FROM documents
@@ -118,7 +157,7 @@ func (s *Store) CreateDocument(name string, docType models.DocumentType) (*model
return documents, nil
}
func (s *Store) UpdateDocumentState(id uuid.UUID, state []byte) error {
func (s *PostgresStore) UpdateDocumentState(id uuid.UUID, state []byte) error {
query := `
UPDATE documents
SET yjs_state = $1, updated_at = $2
@@ -142,7 +181,7 @@ func (s *Store) CreateDocument(name string, docType models.DocumentType) (*model
return nil
}
func (s *Store) DeleteDocument(id uuid.UUID) error {
func (s *PostgresStore) DeleteDocument(id uuid.UUID) error {
query := `DELETE FROM documents WHERE id = $1`
result, err := s.db.Exec(query, id)
@@ -162,3 +201,88 @@ func (s *Store) CreateDocument(name string, docType models.DocumentType) (*model
return nil
}
// CreateDocumentWithOwner creates a new document with owner
func (s *PostgresStore) CreateDocumentWithOwner(name string, docType models.DocumentType, ownerID *uuid.UUID) (*models.Document, error) {
// 1. 检查 docType 是否为空,或者是否合法 (防止 check constraint 报错)
if docType == "" {
docType = models.DocumentTypeEditor // Default to editor instead of invalid "text"
}
// Validate that docType is one of the allowed values
if docType != models.DocumentTypeEditor && docType != models.DocumentTypeKanban {
return nil, fmt.Errorf("invalid document type: %s (must be 'editor' or 'kanban')", docType)
}
doc := &models.Document{
ID: uuid.New(),
Name: name,
Type: docType,
YjsState: []byte{}, // 这里初始化了空字节
OwnerID: ownerID,
Is_Public: false, // 显式设置默认值
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 2. 补全了 yjs_state 和 is_public
query := `
INSERT INTO documents (id, name, type, owner_id, yjs_state, is_public, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, name, type, owner_id, yjs_state, is_public, created_at, updated_at
`
// 3. Scan 的时候也要对应加上
err := s.db.QueryRow(query,
doc.ID,
doc.Name,
doc.Type,
doc.OwnerID,
doc.YjsState, // $5
doc.Is_Public, // $6
doc.CreatedAt,
doc.UpdatedAt,
).Scan(
&doc.ID,
&doc.Name,
&doc.Type,
&doc.OwnerID,
&doc.YjsState, // Scan 回来
&doc.Is_Public, // Scan 回来
&doc.CreatedAt,
&doc.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to create document: %w", err)
}
return doc, nil
}
// ListUserDocuments lists documents owned by or shared with a user
func (s *PostgresStore) ListUserDocuments(ctx context.Context, userID uuid.UUID) ([]models.Document, error) {
query := `
SELECT DISTINCT d.id, d.name, d.type, d.owner_id, d.created_at, d.updated_at
FROM documents d
LEFT JOIN document_shares ds ON d.id = ds.document_id
WHERE d.owner_id = $1 OR ds.user_id = $1
ORDER BY d.created_at DESC
`
rows, err := s.db.QueryContext(ctx, query, userID)
if err != nil {
return nil, fmt.Errorf("failed to list user documents: %w", err)
}
defer rows.Close()
var documents []models.Document
for rows.Next() {
var doc models.Document
err := rows.Scan(&doc.ID, &doc.Name, &doc.Type, &doc.OwnerID, &doc.CreatedAt, &doc.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("failed to scan document: %w", err)
}
documents = append(documents, doc)
}
return documents, nil
}

View File

@@ -0,0 +1,88 @@
package store
import (
"context"
"crypto/sha256"
"encoding/hex"
"time"
"github.com/M1ngdaXie/realtime-collab/internal/models"
"github.com/google/uuid"
)
// CreateSession creates a new session
func (s *PostgresStore) CreateSession(ctx context.Context, userID uuid.UUID, sessionID uuid.UUID, token string, expiresAt time.Time, userAgent, ipAddress *string) (*models.Session, error) {
// Hash the token before storing
hash := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(hash[:])
// 【修改点 1】: 在 SQL 里显式加上 id 字段
// 注意:$1 变成了 id后面的参数序号全部要顺延 (+1)
query := `
INSERT INTO sessions (id, user_id, token_hash, expires_at, user_agent, ip_address)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, token_hash, expires_at, created_at, user_agent, ip_address
`
var session models.Session
// 【修改点 2】: 在参数列表的最前面加上 sessionID
// 现在的对应关系:
// $1 -> sessionID
// $2 -> userID
// $3 -> tokenHash
// ...
err := s.db.QueryRowContext(ctx, query,
sessionID, // <--- 这里!把它传进去!
userID,
tokenHash,
expiresAt,
userAgent,
ipAddress,
).Scan(
&session.ID, &session.UserID, &session.TokenHash, &session.ExpiresAt,
&session.CreatedAt, &session.UserAgent, &session.IPAddress,
)
if err != nil {
return nil, err
}
return &session, nil
}
// GetSessionByToken retrieves session by JWT token
func (s *PostgresStore) GetSessionByToken(ctx context.Context, token string) (*models.Session, error) {
hash := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(hash[:])
query := `
SELECT id, user_id, token_hash, expires_at, created_at, user_agent, ip_address
FROM sessions
WHERE token_hash = $1 AND expires_at > NOW()
`
var session models.Session
err := s.db.QueryRowContext(ctx, query, tokenHash).Scan(
&session.ID, &session.UserID, &session.TokenHash, &session.ExpiresAt,
&session.CreatedAt, &session.UserAgent, &session.IPAddress,
)
if err != nil {
return nil, err
}
return &session, nil
}
// DeleteSession deletes a session (logout)
func (s *PostgresStore) DeleteSession(ctx context.Context, token string) error {
hash := sha256.Sum256([]byte(token))
tokenHash := hex.EncodeToString(hash[:])
_, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE token_hash = $1", tokenHash)
return err
}
// CleanupExpiredSessions removes expired sessions
func (s *PostgresStore) CleanupExpiredSessions(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE expires_at < NOW()")
return err
}

View File

@@ -0,0 +1,193 @@
package store
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
"github.com/M1ngdaXie/realtime-collab/internal/models"
"github.com/google/uuid"
)
// CreateDocumentShare creates a new share
func (s *PostgresStore) CreateDocumentShare(ctx context.Context, documentID, userID uuid.UUID, permission string, createdBy *uuid.UUID) (*models.DocumentShare, error) {
query := `
INSERT INTO document_shares (document_id, user_id, permission, created_by)
VALUES ($1, $2, $3, $4)
ON CONFLICT (document_id, user_id) DO UPDATE SET permission = EXCLUDED.permission
RETURNING id, document_id, user_id, permission, created_at, created_by
`
var share models.DocumentShare
err := s.db.QueryRowContext(ctx, query, documentID, userID, permission, createdBy).Scan(
&share.ID, &share.DocumentID, &share.UserID, &share.Permission,
&share.CreatedAt, &share.CreatedBy,
)
if err != nil {
return nil, err
}
return &share, nil
}
// ListDocumentShares lists all shares for a document
func (s *PostgresStore) ListDocumentShares(ctx context.Context, documentID uuid.UUID) ([]models.DocumentShareWithUser, error) {
query := `
SELECT
ds.id, ds.document_id, ds.user_id, ds.permission, ds.created_at, ds.created_by,
u.id, u.email, u.name, u.avatar_url, u.provider, u.provider_user_id, u.created_at, u.updated_at, u.last_login_at
FROM document_shares ds
JOIN users u ON ds.user_id = u.id
WHERE ds.document_id = $1
ORDER BY ds.created_at DESC
`
rows, err := s.db.QueryContext(ctx, query, documentID)
if err != nil {
return nil, err
}
defer rows.Close()
var shares []models.DocumentShareWithUser
for rows.Next() {
var share models.DocumentShareWithUser
err := rows.Scan(
&share.ID, &share.DocumentID, &share.UserID, &share.Permission, &share.CreatedAt, &share.CreatedBy,
&share.User.ID, &share.User.Email, &share.User.Name, &share.User.AvatarURL, &share.User.Provider,
&share.User.ProviderUserID, &share.User.CreatedAt, &share.User.UpdatedAt, &share.User.LastLoginAt,
)
if err != nil {
return nil, err
}
shares = append(shares, share)
}
return shares, nil
}
// DeleteDocumentShare deletes a share
func (s *PostgresStore) DeleteDocumentShare(ctx context.Context, documentID, userID uuid.UUID) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM document_shares WHERE document_id = $1 AND user_id = $2", documentID, userID)
return err
}
// CanViewDocument checks if user can view document (owner OR has any share)
func (s *PostgresStore) CanViewDocument(ctx context.Context, documentID, userID uuid.UUID) (bool, error) {
query := `
SELECT EXISTS(
SELECT 1 FROM documents WHERE id = $1 AND owner_id = $2
UNION
SELECT 1 FROM document_shares WHERE document_id = $1 AND user_id = $2
)
`
var canView bool
err := s.db.QueryRowContext(ctx, query, documentID, userID).Scan(&canView)
return canView, err
}
// CanEditDocument checks if user can edit document (owner OR has edit share)
func (s *PostgresStore) CanEditDocument(ctx context.Context, documentID, userID uuid.UUID) (bool, error) {
query := `
SELECT EXISTS(
SELECT 1 FROM documents WHERE id = $1 AND owner_id = $2
UNION
SELECT 1 FROM document_shares WHERE document_id = $1 AND user_id = $2 AND permission = 'edit'
)
`
var canEdit bool
err := s.db.QueryRowContext(ctx, query, documentID, userID).Scan(&canEdit)
return canEdit, err
}
// IsDocumentOwner checks if user is the owner
func (s *PostgresStore) IsDocumentOwner(ctx context.Context, documentID, userID uuid.UUID) (bool, error) {
query := `SELECT owner_id = $2 FROM documents WHERE id = $1`
var isOwner bool
err := s.db.QueryRowContext(ctx, query, documentID, userID).Scan(&isOwner)
if err == sql.ErrNoRows {
return false, nil
}
return isOwner, err
}
func (s *PostgresStore) GenerateShareToken(ctx context.Context, documentID uuid.UUID, permission string) (string, error) {
// Generate random 32-byte token
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", fmt.Errorf("failed to generate token: %w", err)
}
token := base64.URLEncoding.EncodeToString(tokenBytes)
// Update document with share token
query := `
UPDATE documents
SET share_token = $1, is_public = true, updated_at = NOW()
WHERE id = $2
RETURNING share_token
`
var shareToken string
err := s.db.QueryRowContext(ctx, query, token, documentID).Scan(&shareToken)
if err != nil {
return "", fmt.Errorf("failed to set share token: %w", err)
}
return shareToken, nil
}
// ValidateShareToken checks if a share token is valid for a document
func (s *PostgresStore) ValidateShareToken(ctx context.Context, documentID uuid.UUID, token string) (bool, error) {
query := `
SELECT EXISTS(
SELECT 1 FROM documents
WHERE id = $1 AND share_token = $2 AND is_public = true
)
`
var exists bool
err := s.db.QueryRowContext(ctx, query, documentID, token).Scan(&exists)
if err != nil {
return false, fmt.Errorf("failed to validate share token: %w", err)
}
return exists, nil
}
// RevokeShareToken removes the public share link from a document
func (s *PostgresStore) RevokeShareToken(ctx context.Context, documentID uuid.UUID) error {
query := `
UPDATE documents
SET share_token = NULL, is_public = false, updated_at = NOW()
WHERE id = $1
`
_, err := s.db.ExecContext(ctx, query, documentID)
if err != nil {
return fmt.Errorf("failed to revoke share token: %w", err)
}
return nil
}
// GetShareToken retrieves the current share token for a document (if exists)
func (s *PostgresStore) GetShareToken(ctx context.Context, documentID uuid.UUID) (string, bool, error) {
query := `
SELECT share_token FROM documents
WHERE id = $1 AND is_public = true AND share_token IS NOT NULL
`
var token string
err := s.db.QueryRowContext(ctx, query, documentID).Scan(&token)
if err == sql.ErrNoRows {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("failed to get share token: %w", err)
}
return token, true, nil
}

View File

@@ -0,0 +1,82 @@
package store
import (
"context"
"database/sql"
"fmt"
"github.com/M1ngdaXie/realtime-collab/internal/models"
"github.com/google/uuid"
)
// UpsertUser creates or updates user from OAuth profile
func (s *PostgresStore) UpsertUser(ctx context.Context, provider, providerUserID, email, name string, avatarURL *string) (*models.User, error) {
query := `
INSERT INTO users (provider, provider_user_id, email, name, avatar_url, last_login_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (provider, provider_user_id)
DO UPDATE SET
email = EXCLUDED.email,
name = EXCLUDED.name,
avatar_url = EXCLUDED.avatar_url,
last_login_at = NOW(),
updated_at = NOW()
RETURNING id, email, name, avatar_url, provider, provider_user_id, created_at, updated_at, last_login_at
`
var user models.User
err := s.db.QueryRowContext(ctx, query, provider, providerUserID, email, name, avatarURL).Scan(
&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.Provider,
&user.ProviderUserID, &user.CreatedAt, &user.UpdatedAt, &user.LastLoginAt,
)
if err != nil {
return nil, err
}
fmt.Printf("✅ User Upserted: ID=%s, Email=%s\n", user.ID.String(), user.Email)
return &user, nil
}
// GetUserByID retrieves user by ID
func (s *PostgresStore) GetUserByID(ctx context.Context, userID uuid.UUID) (*models.User, error) {
query := `
SELECT id, email, name, avatar_url, provider, provider_user_id, created_at, updated_at, last_login_at
FROM users WHERE id = $1
`
var user models.User
err := s.db.QueryRowContext(ctx, query, userID).Scan(
&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.Provider,
&user.ProviderUserID, &user.CreatedAt, &user.UpdatedAt, &user.LastLoginAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &user, nil
}
// GetUserByEmail retrieves user by email
func (s *PostgresStore) GetUserByEmail(ctx context.Context, email string) (*models.User, error) {
query := `
SELECT id, email, name, avatar_url, provider, provider_user_id, created_at, updated_at, last_login_at
FROM users WHERE email = $1
`
var user models.User
err := s.db.QueryRowContext(ctx, query, email).Scan(
&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.Provider,
&user.ProviderUserID, &user.CreatedAt, &user.UpdatedAt, &user.LastLoginAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &user, nil
}