set up for deployment
This commit is contained in:
44
backend/.dockerignore
Normal file
44
backend/.dockerignore
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Environment files (don't include in image, use Fly.io secrets)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
*_test.go
|
||||||
|
testdata
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
README.md
|
||||||
|
|
||||||
|
# Scripts (migration files should be run separately)
|
||||||
|
scripts
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.log
|
||||||
|
tmp
|
||||||
|
temp
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
server
|
||||||
|
main
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
52
backend/Dockerfile
Normal file
52
backend/Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM golang:1.25.3-alpine AS builder
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache git ca-certificates tzdata
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy go mod files
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
# CGO_ENABLED=0 for static binary, GOOS=linux for Linux compatibility
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o server ./cmd/server/main.go
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
# Install CA certificates for HTTPS requests (OAuth)
|
||||||
|
RUN apk --no-cache add ca-certificates tzdata
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1000 appuser && \
|
||||||
|
adduser -D -u 1000 -G appuser appuser
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /app/server .
|
||||||
|
|
||||||
|
# Change ownership to non-root user
|
||||||
|
RUN chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Expose port (Fly.io uses PORT env variable)
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["./server"]
|
||||||
22
backend/fly.toml
Normal file
22
backend/fly.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# fly.toml app configuration file generated for docnest-backend-mingda on 2026-01-11T23:46:03-08:00
|
||||||
|
#
|
||||||
|
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||||
|
#
|
||||||
|
|
||||||
|
app = 'docnest-backend-mingda'
|
||||||
|
primary_region = 'sjc'
|
||||||
|
|
||||||
|
[build]
|
||||||
|
|
||||||
|
[http_service]
|
||||||
|
internal_port = 8080
|
||||||
|
force_https = true
|
||||||
|
auto_stop_machines = 'stop'
|
||||||
|
auto_start_machines = true
|
||||||
|
min_machines_running = 0
|
||||||
|
processes = ['app']
|
||||||
|
|
||||||
|
[[vm]]
|
||||||
|
memory = '1gb'
|
||||||
|
cpus = 1
|
||||||
|
memory_mb = 1024
|
||||||
44
backend/internal/models/version.go
Normal file
44
backend/internal/models/version.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DocumentVersion represents a snapshot of a document at a point in time
|
||||||
|
type DocumentVersion struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
DocumentID uuid.UUID `json:"document_id"`
|
||||||
|
YjsSnapshot []byte `json:"-"` // Omit from JSON (binary)
|
||||||
|
TextPreview *string `json:"text_preview"` // Full plain text
|
||||||
|
VersionNumber int `json:"version_number"`
|
||||||
|
CreatedBy *uuid.UUID `json:"created_by"`
|
||||||
|
VersionLabel *string `json:"version_label"`
|
||||||
|
IsAutoGenerated bool `json:"is_auto_generated"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentVersionWithAuthor includes author information
|
||||||
|
type DocumentVersionWithAuthor struct {
|
||||||
|
DocumentVersion
|
||||||
|
Author *User `json:"author,omitempty"` // Nullable if user deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateVersionRequest is the API request for manual snapshots
|
||||||
|
type CreateVersionRequest struct {
|
||||||
|
VersionLabel *string `json:"version_label"` // Optional user label
|
||||||
|
TextPreview *string `json:"text_preview"` // Frontend-generated full text
|
||||||
|
// YjsSnapshot sent as multipart file, not JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreVersionRequest specifies which version to restore
|
||||||
|
type RestoreVersionRequest struct {
|
||||||
|
VersionID uuid.UUID `json:"version_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionListResponse for listing versions
|
||||||
|
type VersionListResponse struct {
|
||||||
|
Versions []DocumentVersionWithAuthor `json:"versions"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
@@ -47,6 +47,13 @@ type Store interface {
|
|||||||
GetUserPermission(ctx context.Context, documentID, userID uuid.UUID) (string, error)
|
GetUserPermission(ctx context.Context, documentID, userID uuid.UUID) (string, error)
|
||||||
GetShareLinkPermission(ctx context.Context, documentID uuid.UUID) (string, error)
|
GetShareLinkPermission(ctx context.Context, documentID uuid.UUID) (string, error)
|
||||||
|
|
||||||
|
// Version operations
|
||||||
|
CreateDocumentVersion(ctx context.Context, documentID, userID uuid.UUID, snapshot []byte, textPreview *string, label *string, isAuto bool) (*models.DocumentVersion, error)
|
||||||
|
ListDocumentVersions(ctx context.Context, documentID uuid.UUID, limit int, offset int) ([]models.DocumentVersionWithAuthor, int, error)
|
||||||
|
GetDocumentVersion(ctx context.Context, versionID uuid.UUID) (*models.DocumentVersion, error)
|
||||||
|
GetLatestDocumentVersion(ctx context.Context, documentID uuid.UUID) (*models.DocumentVersion, error)
|
||||||
|
|
||||||
|
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
194
backend/internal/store/version.go
Normal file
194
backend/internal/store/version.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/M1ngdaXie/realtime-collab/internal/models"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateDocumentVersion creates a new version snapshot
|
||||||
|
func (s *PostgresStore) CreateDocumentVersion(
|
||||||
|
ctx context.Context,
|
||||||
|
documentID, userID uuid.UUID,
|
||||||
|
snapshot []byte,
|
||||||
|
textPreview *string,
|
||||||
|
label *string,
|
||||||
|
isAuto bool,
|
||||||
|
) (*models.DocumentVersion, error) {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Get next version number
|
||||||
|
var versionNumber int
|
||||||
|
err = tx.QueryRowContext(ctx,
|
||||||
|
`SELECT get_next_version_number($1)`,
|
||||||
|
documentID,
|
||||||
|
).Scan(&versionNumber)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get version number: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert version
|
||||||
|
query := `
|
||||||
|
INSERT INTO document_versions
|
||||||
|
(document_id, yjs_snapshot, text_preview, version_number, created_by, version_label, is_auto_generated)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, document_id, text_preview, version_number, created_by, version_label, is_auto_generated, created_at
|
||||||
|
`
|
||||||
|
|
||||||
|
var version models.DocumentVersion
|
||||||
|
err = tx.QueryRowContext(ctx, query,
|
||||||
|
documentID, snapshot, textPreview, versionNumber, userID, label, isAuto,
|
||||||
|
).Scan(
|
||||||
|
&version.ID, &version.DocumentID, &version.TextPreview, &version.VersionNumber,
|
||||||
|
&version.CreatedBy, &version.VersionLabel, &version.IsAutoGenerated, &version.CreatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update document metadata
|
||||||
|
_, err = tx.ExecContext(ctx, `
|
||||||
|
UPDATE documents
|
||||||
|
SET version_count = version_count + 1,
|
||||||
|
last_snapshot_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`, documentID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to update document metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDocumentVersions returns paginated version list with author info
|
||||||
|
func (s *PostgresStore) ListDocumentVersions(
|
||||||
|
ctx context.Context,
|
||||||
|
documentID uuid.UUID,
|
||||||
|
limit int,
|
||||||
|
offset int,
|
||||||
|
) ([]models.DocumentVersionWithAuthor, int, error) {
|
||||||
|
// Get total count
|
||||||
|
var total int
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT COUNT(*) FROM document_versions WHERE document_id = $1`,
|
||||||
|
documentID,
|
||||||
|
).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to count versions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated versions with author (LEFT JOIN to handle deleted users)
|
||||||
|
query := `
|
||||||
|
SELECT
|
||||||
|
v.id, v.document_id, v.text_preview, v.version_number,
|
||||||
|
v.created_by, v.version_label, v.is_auto_generated, v.created_at,
|
||||||
|
u.id, u.email, u.name, u.avatar_url
|
||||||
|
FROM document_versions v
|
||||||
|
LEFT JOIN users u ON v.created_by = u.id
|
||||||
|
WHERE v.document_id = $1
|
||||||
|
ORDER BY v.version_number DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, documentID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to list versions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var versions []models.DocumentVersionWithAuthor
|
||||||
|
for rows.Next() {
|
||||||
|
var v models.DocumentVersionWithAuthor
|
||||||
|
var authorID, authorEmail, authorName sql.NullString
|
||||||
|
var authorAvatar *string
|
||||||
|
|
||||||
|
err := rows.Scan(
|
||||||
|
&v.ID, &v.DocumentID, &v.TextPreview, &v.VersionNumber,
|
||||||
|
&v.CreatedBy, &v.VersionLabel, &v.IsAutoGenerated, &v.CreatedAt,
|
||||||
|
&authorID, &authorEmail, &authorName, &authorAvatar,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to scan version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate author if exists
|
||||||
|
if authorID.Valid {
|
||||||
|
authorUUID, _ := uuid.Parse(authorID.String)
|
||||||
|
v.Author = &models.User{
|
||||||
|
ID: authorUUID,
|
||||||
|
Email: authorEmail.String,
|
||||||
|
Name: authorName.String,
|
||||||
|
AvatarURL: authorAvatar,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
versions = append(versions, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return versions, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDocumentVersion retrieves a specific version with full snapshot
|
||||||
|
func (s *PostgresStore) GetDocumentVersion(ctx context.Context, versionID uuid.UUID) (*models.DocumentVersion, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, document_id, yjs_snapshot, text_preview, ve rsion_number,
|
||||||
|
created_by, version_label, is_auto_generated, created_at
|
||||||
|
FROM document_versions
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
var version models.DocumentVersion
|
||||||
|
err := s.db.QueryRowContext(ctx, query, versionID).Scan(
|
||||||
|
&version.ID, &version.DocumentID, &version.YjsSnapshot, &version.TextPreview,
|
||||||
|
&version.VersionNumber, &version.CreatedBy, &version.VersionLabel,
|
||||||
|
&version.IsAutoGenerated, &version.CreatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("version not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestDocumentVersion gets the most recent version for auto-snapshot comparison
|
||||||
|
func (s *PostgresStore) GetLatestDocumentVersion(ctx context.Context, documentID uuid.UUID) (*models.DocumentVersion, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, document_id, yjs_snapshot, text_preview, version_number,
|
||||||
|
created_by, version_label, is_auto_generated, created_at
|
||||||
|
FROM document_versions
|
||||||
|
WHERE document_id = $1
|
||||||
|
ORDER BY version_number DESC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
var version models.DocumentVersion
|
||||||
|
err := s.db.QueryRowContext(ctx, query, documentID).Scan(
|
||||||
|
&version.ID, &version.DocumentID, &version.YjsSnapshot, &version.TextPreview,
|
||||||
|
&version.VersionNumber, &version.CreatedBy, &version.VersionLabel,
|
||||||
|
&version.IsAutoGenerated, &version.CreatedAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil // No versions yet
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get latest version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &version, nil
|
||||||
|
}
|
||||||
8
backend/scripts/000_extensions.sql
Normal file
8
backend/scripts/000_extensions.sql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
-- Migration: Create required PostgreSQL extensions
|
||||||
|
-- Extensions must be created before other migrations can use them
|
||||||
|
|
||||||
|
-- uuid-ossp: Provides functions for generating UUIDs (uuid_generate_v4())
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- pgcrypto: Provides cryptographic functions (used for token hashing)
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
25
backend/scripts/001_init_schema.sql
Normal file
25
backend/scripts/001_init_schema.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- Initialize database schema for realtime collaboration
|
||||||
|
-- This is the base schema that creates core tables for documents and updates
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
type VARCHAR(50) NOT NULL CHECK (type IN ('editor', 'kanban')),
|
||||||
|
yjs_state BYTEA,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_documents_type ON documents(type);
|
||||||
|
CREATE INDEX idx_documents_created_at ON documents(created_at DESC);
|
||||||
|
|
||||||
|
-- Table for storing incremental updates (for history tracking)
|
||||||
|
CREATE TABLE IF NOT EXISTS document_updates (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
update BYTEA NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_updates_document_id ON document_updates(document_id);
|
||||||
|
CREATE INDEX idx_updates_created_at ON document_updates(created_at DESC);
|
||||||
20
backend/scripts/006_add_oauth_tokens.sql
Normal file
20
backend/scripts/006_add_oauth_tokens.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
-- Migration: Add OAuth token storage
|
||||||
|
-- This table stores OAuth2 access tokens and refresh tokens from external providers
|
||||||
|
-- Used for refreshing user sessions without re-authentication
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
provider VARCHAR(50) NOT NULL,
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
refresh_token TEXT,
|
||||||
|
token_type VARCHAR(50) DEFAULT 'Bearer',
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
scope TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT oauth_tokens_user_id_provider_key UNIQUE (user_id, provider)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_oauth_tokens_user_id ON oauth_tokens(user_id);
|
||||||
41
backend/scripts/007_complete_version_migration.sql
Normal file
41
backend/scripts/007_complete_version_migration.sql
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
-- Migration: Add document version history support
|
||||||
|
-- This migration creates the version history table, adds tracking columns,
|
||||||
|
-- and provides a helper function for version numbering
|
||||||
|
|
||||||
|
-- Create document versions table for storing version snapshots
|
||||||
|
CREATE TABLE IF NOT EXISTS document_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
yjs_snapshot BYTEA NOT NULL,
|
||||||
|
text_preview TEXT,
|
||||||
|
version_number INTEGER NOT NULL,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
version_label TEXT,
|
||||||
|
is_auto_generated BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT unique_document_version UNIQUE(document_id, version_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_document_versions_document_id ON document_versions(document_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_document_versions_created_by ON document_versions(created_by);
|
||||||
|
|
||||||
|
-- Add version tracking columns to documents table
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS version_count INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS last_snapshot_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Function to get the next version number for a document
|
||||||
|
-- This ensures version numbers are sequential and unique per document
|
||||||
|
CREATE OR REPLACE FUNCTION get_next_version_number(p_document_id UUID)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
next_version INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(MAX(version_number), 0) + 1
|
||||||
|
INTO next_version
|
||||||
|
FROM document_versions
|
||||||
|
WHERE document_id = p_document_id;
|
||||||
|
|
||||||
|
RETURN next_version;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
36
backend/scripts/008_enable_rls.sql
Normal file
36
backend/scripts/008_enable_rls.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
-- Migration: Enable Row Level Security (RLS) on all tables
|
||||||
|
-- This enables RLS but uses permissive policies to allow all operations
|
||||||
|
-- Authorization is still handled by the Go backend middleware
|
||||||
|
|
||||||
|
-- Enable RLS on all tables
|
||||||
|
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE sessions ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE oauth_tokens ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE document_updates ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE document_shares ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE document_versions ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Create permissive policies that allow all operations
|
||||||
|
-- This maintains current behavior where backend handles authorization
|
||||||
|
|
||||||
|
-- Users table
|
||||||
|
CREATE POLICY "Allow all operations on users" ON users FOR ALL USING (true);
|
||||||
|
|
||||||
|
-- Sessions table
|
||||||
|
CREATE POLICY "Allow all operations on sessions" ON sessions FOR ALL USING (true);
|
||||||
|
|
||||||
|
-- OAuth tokens table
|
||||||
|
CREATE POLICY "Allow all operations on oauth_tokens" ON oauth_tokens FOR ALL USING (true);
|
||||||
|
|
||||||
|
-- Documents table
|
||||||
|
CREATE POLICY "Allow all operations on documents" ON documents FOR ALL USING (true);
|
||||||
|
|
||||||
|
-- Document updates table
|
||||||
|
CREATE POLICY "Allow all operations on document_updates" ON document_updates FOR ALL USING (true);
|
||||||
|
|
||||||
|
-- Document shares table
|
||||||
|
CREATE POLICY "Allow all operations on document_shares" ON document_shares FOR ALL USING (true);
|
||||||
|
|
||||||
|
-- Document versions table
|
||||||
|
CREATE POLICY "Allow all operations on document_versions" ON document_versions FOR ALL USING (true);
|
||||||
16
backend/scripts/009_disable_postgrest.sql
Normal file
16
backend/scripts/009_disable_postgrest.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration: Revoke PostgREST access to public schema
|
||||||
|
-- This prevents Supabase's auto-generated REST API from exposing tables
|
||||||
|
-- Use this if you ONLY connect via your Go backend, not via Supabase client libraries
|
||||||
|
|
||||||
|
-- Revoke access from anon and authenticated roles (used by PostgREST)
|
||||||
|
REVOKE ALL ON ALL TABLES IN SCHEMA public FROM anon, authenticated;
|
||||||
|
REVOKE ALL ON ALL SEQUENCES IN SCHEMA public FROM anon, authenticated;
|
||||||
|
REVOKE ALL ON ALL FUNCTIONS IN SCHEMA public FROM anon, authenticated;
|
||||||
|
|
||||||
|
-- Grant access only to postgres role (your backend connection)
|
||||||
|
GRANT ALL ON ALL TABLES IN SCHEMA public TO postgres;
|
||||||
|
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO postgres;
|
||||||
|
GRANT ALL ON ALL FUNCTIONS IN SCHEMA public TO postgres;
|
||||||
|
|
||||||
|
-- Note: Run this AFTER all other migrations
|
||||||
|
-- If you need PostgREST access later, you can re-grant permissions selectively
|
||||||
38
backend/scripts/archive/migration_add_versions.sql
Normal file
38
backend/scripts/archive/migration_add_versions.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- Migration: Add document version history support
|
||||||
|
-- Run: psql -U postgres collaboration < backend/scripts/migration_add_versions.sql
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS document_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
yjs_snapshot BYTEA NOT NULL,
|
||||||
|
text_preview TEXT,
|
||||||
|
version_number INTEGER NOT NULL,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
version_label TEXT,
|
||||||
|
is_auto_generated BOOLEAN DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT unique_document_version UNIQUE(document_id, version_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_document_versions_document_id ON document_versions(document_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_document_versions_created_by ON document_versions(created_by);
|
||||||
|
|
||||||
|
-- Add version tracking to documents table
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS version_count INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS last_snapshot_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Function to get next version number
|
||||||
|
CREATE OR REPLACE FUNCTION get_next_version_number(p_document_id UUID)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
next_version INTEGER;
|
||||||
|
BEGIN
|
||||||
|
SELECT COALESCE(MAX(version_number), 0) + 1
|
||||||
|
INTO next_version
|
||||||
|
FROM document_versions
|
||||||
|
WHERE document_id = p_document_id;
|
||||||
|
|
||||||
|
RETURN next_version;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
@@ -3,11 +3,18 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080/api"
|
|||||||
export async function authFetch(url: string, options?: RequestInit): Promise<Response> {
|
export async function authFetch(url: string, options?: RequestInit): Promise<Response> {
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = localStorage.getItem('auth_token');
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...options?.headers,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Merge existing headers if provided
|
||||||
|
if (options?.headers) {
|
||||||
|
const existingHeaders = new Headers(options.headers);
|
||||||
|
existingHeaders.forEach((value, key) => {
|
||||||
|
headers[key] = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add Authorization header if token exists
|
// Add Authorization header if token exists
|
||||||
if (token) {
|
if (token) {
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ const KanbanBoard = ({ providers }: KanbanBoardProps) => {
|
|||||||
|
|
||||||
if (columnIndex !== -1) {
|
if (columnIndex !== -1) {
|
||||||
providers.ydoc.transact(() => {
|
providers.ydoc.transact(() => {
|
||||||
const column = cols[columnIndex];
|
const column = cols[columnIndex] as KanbanColumn;
|
||||||
column.tasks.push(task);
|
column.tasks.push(task);
|
||||||
yarray.delete(columnIndex, 1);
|
yarray.delete(columnIndex, 1);
|
||||||
yarray.insert(columnIndex, [column]);
|
yarray.insert(columnIndex, [column]);
|
||||||
@@ -91,8 +91,8 @@ const KanbanBoard = ({ providers }: KanbanBoardProps) => {
|
|||||||
|
|
||||||
if (fromIndex !== -1 && toIndex !== -1) {
|
if (fromIndex !== -1 && toIndex !== -1) {
|
||||||
providers.ydoc.transact(() => {
|
providers.ydoc.transact(() => {
|
||||||
const fromCol = { ...cols[fromIndex] };
|
const fromCol = { ...(cols[fromIndex] as KanbanColumn) };
|
||||||
const toCol = { ...cols[toIndex] };
|
const toCol = { ...(cols[toIndex] as KanbanColumn) };
|
||||||
|
|
||||||
const taskIndex = fromCol.tasks.findIndex((t: Task) => t.id === taskId);
|
const taskIndex = fromCol.tasks.findIndex((t: Task) => t.id === taskId);
|
||||||
if (taskIndex !== -1) {
|
if (taskIndex !== -1) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
|
|
||||||
interface FloatingGemProps {
|
interface FloatingGemProps {
|
||||||
position?: { top?: string; right?: string; bottom?: string; left?: string };
|
position?: { top?: string; right?: string; bottom?: string; left?: string };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import type { User, AuthContextType } from '../types/auth';
|
import type { User, AuthContextType } from '../types/auth';
|
||||||
import { authApi } from '../api/auth';
|
import { authApi } from '../api/auth';
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import * as Y from 'yjs';
|
|||||||
import { documentsApi } from '../api/document';
|
import { documentsApi } from '../api/document';
|
||||||
|
|
||||||
export const useAutoSave = (documentId: string, ydoc: Y.Doc | null) => {
|
export const useAutoSave = (documentId: string, ydoc: Y.Doc | null) => {
|
||||||
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const saveTimeoutRef = useRef<number | null>(null);
|
||||||
const isSavingRef = useRef(false);
|
const isSavingRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ydoc) return;
|
if (!ydoc) return;
|
||||||
|
|
||||||
const handleUpdate = (update: Uint8Array, origin: any) => {
|
const handleUpdate = (_update: Uint8Array, origin: any) => {
|
||||||
// Ignore updates from initial sync or remote sources
|
// Ignore updates from initial sync or remote sources
|
||||||
if (origin === 'init' || origin === 'remote') return;
|
if (origin === 'init' || origin === 'remote') return;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { WebsocketProvider } from "y-websocket";
|
|||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import { documentsApi } from "../api/document";
|
import { documentsApi } from "../api/document";
|
||||||
|
|
||||||
const WS_URL = import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws";
|
const WS_URL = import.meta.env.VITE_WS_URL;
|
||||||
|
|
||||||
export interface YjsProviders {
|
export interface YjsProviders {
|
||||||
ydoc: Y.Doc;
|
ydoc: Y.Doc;
|
||||||
@@ -21,7 +21,7 @@ export interface YjsUser {
|
|||||||
|
|
||||||
export const createYjsDocument = async (
|
export const createYjsDocument = async (
|
||||||
documentId: string,
|
documentId: string,
|
||||||
user: YjsUser,
|
_user: YjsUser,
|
||||||
token: string,
|
token: string,
|
||||||
shareToken?: string
|
shareToken?: string
|
||||||
): Promise<YjsProviders> => {
|
): Promise<YjsProviders> => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import Editor from "../components/Editor/Editor.tsx";
|
import Editor from "../components/Editor/Editor.tsx";
|
||||||
|
import Navbar from "../components/Navbar.tsx";
|
||||||
import UserList from "../components/Presence/UserList.tsx";
|
import UserList from "../components/Presence/UserList.tsx";
|
||||||
import ShareModal from "../components/Share/ShareModal.tsx";
|
import ShareModal from "../components/Share/ShareModal.tsx";
|
||||||
import Navbar from "../components/Navbar.tsx";
|
|
||||||
import { useYjsDocument } from "../hooks/useYjsDocument.ts";
|
import { useYjsDocument } from "../hooks/useYjsDocument.ts";
|
||||||
|
|
||||||
const EditorPage = () => {
|
const EditorPage = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user