feat: implement unified editor with content persistence and server-side upsert
- Replace dual update systems with single markdown-first editor architecture - Add server-side upsert to eliminate 404 errors on PUT operations - Fix content persistence race condition between preview and save operations - Remove legacy updateElementContent system entirely - Add comprehensive authentication with JWT scaffolding and dev mode - Implement EditContext.updateOriginalContent() for proper baseline management - Enable markdown formatting in all text elements (h1-h6, p, div, etc) - Clean terminology: remove 'unified' references from codebase Technical changes: * core/editor.js: Remove legacy update system, unify content types as markdown * ui/Editor.js: Add updateOriginalContent() method to fix save persistence * ui/Previewer.js: Clean live preview system for all content types * api/handlers.go: Implement UpsertContent for idempotent PUT operations * auth/*: Complete authentication service with OAuth scaffolding * db/queries/content.sql: Add upsert query with ON CONFLICT handling * Schema: Remove type constraints, rely on server-side validation Result: Clean content editing with persistent saves, no 404 errors, markdown support in all text elements
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/insertr/insertr/internal/auth"
|
||||
"github.com/insertr/insertr/internal/db"
|
||||
"github.com/insertr/insertr/internal/db/postgresql"
|
||||
"github.com/insertr/insertr/internal/db/sqlite"
|
||||
@@ -18,13 +19,15 @@ import (
|
||||
|
||||
// ContentHandler handles all content-related HTTP requests
|
||||
type ContentHandler struct {
|
||||
database *db.Database
|
||||
database *db.Database
|
||||
authService *auth.AuthService
|
||||
}
|
||||
|
||||
// NewContentHandler creates a new content handler
|
||||
func NewContentHandler(database *db.Database) *ContentHandler {
|
||||
func NewContentHandler(database *db.Database, authService *auth.AuthService) *ContentHandler {
|
||||
return &ContentHandler{
|
||||
database: database,
|
||||
database: database,
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,14 +176,13 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
|
||||
siteID = "default" // final fallback
|
||||
}
|
||||
|
||||
// Extract user from request (for now, use X-User-ID header or fallback)
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" && req.CreatedBy != "" {
|
||||
userID = req.CreatedBy
|
||||
}
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
// Extract user from request using authentication service
|
||||
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
|
||||
if authErr != nil {
|
||||
http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
userID := userInfo.ID
|
||||
|
||||
var content interface{}
|
||||
var err error
|
||||
@@ -219,7 +221,7 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(item)
|
||||
}
|
||||
|
||||
// UpdateContent handles PUT /api/content/{id}
|
||||
// UpdateContent handles PUT /api/content/{id} with upsert functionality
|
||||
func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
contentID := vars["id"]
|
||||
@@ -236,29 +238,70 @@ func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user from request
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" && req.UpdatedBy != "" {
|
||||
userID = req.UpdatedBy
|
||||
// Extract user from request using authentication service
|
||||
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
|
||||
if authErr != nil {
|
||||
http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
userID := userInfo.ID
|
||||
|
||||
// Check if content exists for version history (non-blocking)
|
||||
var existingContent interface{}
|
||||
var contentExists bool
|
||||
|
||||
switch h.database.GetDBType() {
|
||||
case "sqlite3":
|
||||
existingContent, _ = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{
|
||||
ID: contentID,
|
||||
SiteID: siteID,
|
||||
})
|
||||
contentExists = existingContent != nil
|
||||
case "postgresql":
|
||||
existingContent, _ = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{
|
||||
ID: contentID,
|
||||
SiteID: siteID,
|
||||
})
|
||||
contentExists = existingContent != nil
|
||||
}
|
||||
|
||||
// Get current content for version history and type preservation
|
||||
var currentContent interface{}
|
||||
// Archive existing version before upsert (only if content already exists)
|
||||
if contentExists {
|
||||
if err := h.createContentVersion(existingContent); err != nil {
|
||||
// Log error but don't fail the request - version history is non-critical
|
||||
fmt.Printf("Warning: Failed to create content version: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine content type: use provided type, fallback to existing type, default to "text"
|
||||
contentType := req.Type
|
||||
if contentType == "" && contentExists {
|
||||
contentType = h.getContentType(existingContent)
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "text" // default type for new content
|
||||
}
|
||||
|
||||
// Perform upsert operation
|
||||
var upsertedContent interface{}
|
||||
var err error
|
||||
|
||||
switch h.database.GetDBType() {
|
||||
case "sqlite3":
|
||||
currentContent, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{
|
||||
ID: contentID,
|
||||
SiteID: siteID,
|
||||
upsertedContent, err = h.database.GetSQLiteQueries().UpsertContent(context.Background(), sqlite.UpsertContentParams{
|
||||
ID: contentID,
|
||||
SiteID: siteID,
|
||||
Value: req.Value,
|
||||
Type: contentType,
|
||||
LastEditedBy: userID,
|
||||
})
|
||||
case "postgresql":
|
||||
currentContent, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{
|
||||
ID: contentID,
|
||||
SiteID: siteID,
|
||||
upsertedContent, err = h.database.GetPostgreSQLQueries().UpsertContent(context.Background(), postgresql.UpsertContentParams{
|
||||
ID: contentID,
|
||||
SiteID: siteID,
|
||||
Value: req.Value,
|
||||
Type: contentType,
|
||||
LastEditedBy: userID,
|
||||
})
|
||||
default:
|
||||
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
|
||||
@@ -266,58 +309,11 @@ func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
http.Error(w, "Content not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
|
||||
http.Error(w, fmt.Sprintf("Failed to upsert content: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Archive current version before updating
|
||||
err = h.createContentVersion(currentContent)
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to create version: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Determine content type
|
||||
contentType := req.Type
|
||||
if contentType == "" {
|
||||
contentType = h.getContentType(currentContent) // preserve existing type if not specified
|
||||
}
|
||||
|
||||
// Update the content
|
||||
var updatedContent interface{}
|
||||
|
||||
switch h.database.GetDBType() {
|
||||
case "sqlite3":
|
||||
updatedContent, err = h.database.GetSQLiteQueries().UpdateContent(context.Background(), sqlite.UpdateContentParams{
|
||||
Value: req.Value,
|
||||
Type: contentType,
|
||||
LastEditedBy: userID,
|
||||
ID: contentID,
|
||||
SiteID: siteID,
|
||||
})
|
||||
case "postgresql":
|
||||
updatedContent, err = h.database.GetPostgreSQLQueries().UpdateContent(context.Background(), postgresql.UpdateContentParams{
|
||||
Value: req.Value,
|
||||
Type: contentType,
|
||||
LastEditedBy: userID,
|
||||
ID: contentID,
|
||||
SiteID: siteID,
|
||||
})
|
||||
default:
|
||||
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("Failed to update content: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
item := h.convertToAPIContent(updatedContent)
|
||||
item := h.convertToAPIContent(upsertedContent)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(item)
|
||||
@@ -459,14 +455,13 @@ func (h *ContentHandler) RollbackContent(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user from request
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" && req.RolledBackBy != "" {
|
||||
userID = req.RolledBackBy
|
||||
}
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
// Extract user from request using authentication service
|
||||
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
|
||||
if authErr != nil {
|
||||
http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
userID := userInfo.ID
|
||||
|
||||
// Archive current version before rollback
|
||||
var currentContent interface{}
|
||||
|
||||
Reference in New Issue
Block a user