package api import ( "context" "database/sql" "encoding/json" "fmt" "io" "log" "net/http" "os" "strconv" "strings" "time" "github.com/go-chi/chi/v5" "github.com/insertr/insertr/internal/auth" "github.com/insertr/insertr/internal/content" "github.com/insertr/insertr/internal/db" "github.com/insertr/insertr/internal/db/postgresql" "github.com/insertr/insertr/internal/db/sqlite" "github.com/insertr/insertr/internal/engine" ) // ContentHandler handles all content-related HTTP requests type ContentHandler struct { database *db.Database authService *auth.AuthService siteManager *content.SiteManager engine *engine.ContentEngine } // NewContentHandler creates a new content handler func NewContentHandler(database *db.Database, authService *auth.AuthService) *ContentHandler { // Create database client for engine dbClient := engine.NewDatabaseClient(database) return &ContentHandler{ database: database, authService: authService, siteManager: nil, // Will be set via SetSiteManager engine: engine.NewContentEngine(dbClient), } } // SetSiteManager sets the site manager for file enhancement func (h *ContentHandler) SetSiteManager(siteManager *content.SiteManager) { h.siteManager = siteManager } // EnhanceSite handles POST /api/enhance - manual site enhancement trigger func (h *ContentHandler) EnhanceSite(w http.ResponseWriter, r *http.Request) { siteID := r.URL.Query().Get("site_id") if siteID == "" { http.Error(w, "site_id parameter is required", http.StatusBadRequest) return } if h.siteManager == nil { http.Error(w, "Site manager not available", http.StatusServiceUnavailable) return } // Check if site is registered site, exists := h.siteManager.GetSite(siteID) if !exists { http.Error(w, fmt.Sprintf("Site %s is not registered", siteID), http.StatusNotFound) return } // Perform enhancement err := h.siteManager.EnhanceSite(siteID) if err != nil { log.Printf("❌ Manual enhancement failed for site %s: %v", siteID, err) http.Error(w, fmt.Sprintf("Enhancement failed: %v", err), http.StatusInternalServerError) return } // Get enhancement statistics stats := h.siteManager.GetStats() // Return success response with details response := map[string]interface{}{ "success": true, "site_id": siteID, "site_path": site.Path, "message": fmt.Sprintf("Successfully enhanced site %s", siteID), "stats": stats, "timestamp": time.Now().Format(time.RFC3339), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) log.Printf("✅ Manual enhancement completed for site %s", siteID) } // GetContent handles GET /api/content/{id} func (h *ContentHandler) GetContent(w http.ResponseWriter, r *http.Request) { contentID := chi.URLParam(r, "id") siteID := r.URL.Query().Get("site_id") if siteID == "" { http.Error(w, "site_id parameter is required", http.StatusBadRequest) return } var content interface{} var err error switch h.database.GetDBType() { case "sqlite3": content, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{ ID: contentID, SiteID: siteID, }) case "postgresql": content, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{ ID: contentID, SiteID: siteID, }) default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } 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) return } item := h.convertToAPIContent(content) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(item) } // GetAllContent handles GET /api/content func (h *ContentHandler) GetAllContent(w http.ResponseWriter, r *http.Request) { siteID := r.URL.Query().Get("site_id") if siteID == "" { http.Error(w, "site_id parameter is required", http.StatusBadRequest) return } var dbContent interface{} var err error switch h.database.GetDBType() { case "sqlite3": dbContent, err = h.database.GetSQLiteQueries().GetAllContent(context.Background(), siteID) case "postgresql": dbContent, err = h.database.GetPostgreSQLQueries().GetAllContent(context.Background(), siteID) default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } if err != nil { http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) return } items := h.convertToAPIContentList(dbContent) response := ContentResponse{Content: items} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // GetBulkContent handles GET /api/content/bulk func (h *ContentHandler) GetBulkContent(w http.ResponseWriter, r *http.Request) { siteID := r.URL.Query().Get("site_id") if siteID == "" { http.Error(w, "site_id parameter is required", http.StatusBadRequest) return } // Parse ids parameter idsParam := r.URL.Query()["ids[]"] if len(idsParam) == 0 { // Try single ids parameter idsStr := r.URL.Query().Get("ids") if idsStr == "" { http.Error(w, "ids parameter is required", http.StatusBadRequest) return } idsParam = strings.Split(idsStr, ",") } var dbContent interface{} var err error switch h.database.GetDBType() { case "sqlite3": dbContent, err = h.database.GetSQLiteQueries().GetBulkContent(context.Background(), sqlite.GetBulkContentParams{ SiteID: siteID, Ids: idsParam, }) case "postgresql": dbContent, err = h.database.GetPostgreSQLQueries().GetBulkContent(context.Background(), postgresql.GetBulkContentParams{ SiteID: siteID, Ids: idsParam, }) default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } if err != nil { http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) return } items := h.convertToAPIContentList(dbContent) response := ContentResponse{Content: items} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // CreateContent handles POST /api/content func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) { var req CreateContentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } siteID := r.URL.Query().Get("site_id") if siteID == "" { siteID = req.SiteID // fallback to request body } if siteID == "" || siteID == "__MISSING_SITE_ID__" { http.Error(w, "site_id parameter is required and must be configured", http.StatusBadRequest) return } // Generate content ID using the unified engine if req.HTMLMarkup == "" { http.Error(w, "html_markup is required", http.StatusBadRequest) return } result, engineErr := h.engine.ProcessContent(engine.ContentInput{ HTML: []byte(req.HTMLMarkup), FilePath: req.FilePath, SiteID: siteID, Mode: engine.IDGeneration, }) if engineErr != nil { http.Error(w, fmt.Sprintf("ID generation failed: %v", engineErr), http.StatusInternalServerError) return } if len(result.Elements) == 0 { http.Error(w, "No insertr elements found in HTML markup", http.StatusBadRequest) return } // Use the ID generated by the engine for the first element contentID := result.Elements[0].ID // 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 // 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 } // 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) } } // HTML-first approach: no content type needed var content interface{} var err error switch h.database.GetDBType() { case "sqlite3": content, err = h.database.GetSQLiteQueries().UpsertContent(context.Background(), sqlite.UpsertContentParams{ ID: contentID, SiteID: siteID, HtmlContent: req.HTMLContent, OriginalTemplate: engine.ToNullString(req.OriginalTemplate), LastEditedBy: userID, }) case "postgresql": content, err = h.database.GetPostgreSQLQueries().UpsertContent(context.Background(), postgresql.UpsertContentParams{ ID: contentID, SiteID: siteID, HtmlContent: req.HTMLContent, OriginalTemplate: engine.ToNullString(req.OriginalTemplate), LastEditedBy: userID, }) default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } if err != nil { http.Error(w, fmt.Sprintf("Failed to upsert content: %v", err), http.StatusInternalServerError) return } item := h.convertToAPIContent(content) // Trigger file enhancement if site is registered for auto-enhancement log.Printf("🔍 Checking auto-enhancement for site: %s", siteID) if h.siteManager == nil { log.Printf("❌ No site manager configured") } else if !h.siteManager.IsAutoEnhanceEnabled(siteID) { log.Printf("❌ Auto-enhancement not enabled for site: %s", siteID) } else { log.Printf("✅ Triggering auto-enhancement for site: %s", siteID) go func() { log.Printf("🔄 Starting enhancement for site: %s", siteID) if err := h.siteManager.EnhanceSite(siteID); err != nil { log.Printf("⚠️ Failed to enhance site %s: %v", siteID, err) } else { log.Printf("✅ Enhanced files for site %s", siteID) } }() } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(item) } // UpdateContent handles PUT /api/content/{id} func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) { contentID := chi.URLParam(r, "id") siteID := r.URL.Query().Get("site_id") if siteID == "" { http.Error(w, "site_id parameter is required", http.StatusBadRequest) return } var req struct { HTMLContent string `json:"html_content"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } // 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 // Get existing content for version history var existingContent interface{} var err error switch h.database.GetDBType() { case "sqlite3": existingContent, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{ ID: contentID, SiteID: siteID, }) case "postgresql": existingContent, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{ ID: contentID, SiteID: siteID, }) default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } 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) return } // Archive existing version before update if err := h.createContentVersion(existingContent); err != nil { fmt.Printf("Warning: Failed to create content version: %v\n", err) } // Update content var updatedContent interface{} switch h.database.GetDBType() { case "sqlite3": updatedContent, err = h.database.GetSQLiteQueries().UpdateContent(context.Background(), sqlite.UpdateContentParams{ HtmlContent: req.HTMLContent, LastEditedBy: userID, ID: contentID, SiteID: siteID, }) case "postgresql": updatedContent, err = h.database.GetPostgreSQLQueries().UpdateContent(context.Background(), postgresql.UpdateContentParams{ HtmlContent: req.HTMLContent, 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) // Trigger file enhancement if site is registered for auto-enhancement if h.siteManager != nil && h.siteManager.IsAutoEnhanceEnabled(siteID) { go func() { if err := h.siteManager.EnhanceSite(siteID); err != nil { log.Printf("⚠️ Failed to enhance site %s: %v", siteID, err) } }() } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(item) } // DeleteContent handles DELETE /api/content/{id} func (h *ContentHandler) DeleteContent(w http.ResponseWriter, r *http.Request) { contentID := chi.URLParam(r, "id") siteID := r.URL.Query().Get("site_id") if siteID == "" { http.Error(w, "site_id parameter is required", http.StatusBadRequest) return } var err error switch h.database.GetDBType() { case "sqlite3": err = h.database.GetSQLiteQueries().DeleteContent(context.Background(), sqlite.DeleteContentParams{ ID: contentID, SiteID: siteID, }) case "postgresql": err = h.database.GetPostgreSQLQueries().DeleteContent(context.Background(), postgresql.DeleteContentParams{ ID: contentID, SiteID: siteID, }) default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } if err != nil { http.Error(w, fmt.Sprintf("Failed to delete content: %v", err), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // GetContentVersions handles GET /api/content/{id}/versions func (h *ContentHandler) GetContentVersions(w http.ResponseWriter, r *http.Request) { contentID := chi.URLParam(r, "id") siteID := r.URL.Query().Get("site_id") if siteID == "" { http.Error(w, "site_id parameter is required", http.StatusBadRequest) return } // Parse limit parameter (default to 10) limit := int64(10) if limitStr := r.URL.Query().Get("limit"); limitStr != "" { if parsedLimit, err := strconv.ParseInt(limitStr, 10, 64); err == nil { limit = parsedLimit } } var dbVersions interface{} var err error switch h.database.GetDBType() { case "sqlite3": dbVersions, err = h.database.GetSQLiteQueries().GetContentVersionHistory(context.Background(), sqlite.GetContentVersionHistoryParams{ ContentID: contentID, SiteID: siteID, LimitCount: limit, }) case "postgresql": // Note: PostgreSQL uses different parameter names due to int32 vs int64 dbVersions, err = h.database.GetPostgreSQLQueries().GetContentVersionHistory(context.Background(), postgresql.GetContentVersionHistoryParams{ ContentID: contentID, SiteID: siteID, LimitCount: int32(limit), }) default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } if err != nil { http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) return } versions := h.convertToAPIVersionList(dbVersions) response := ContentVersionsResponse{Versions: versions} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // RollbackContent handles POST /api/content/{id}/rollback func (h *ContentHandler) RollbackContent(w http.ResponseWriter, r *http.Request) { contentID := chi.URLParam(r, "id") siteID := r.URL.Query().Get("site_id") if siteID == "" { http.Error(w, "site_id parameter is required", http.StatusBadRequest) return } var req RollbackContentRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } // Get the target version var targetVersion interface{} var err error switch h.database.GetDBType() { case "sqlite3": targetVersion, err = h.database.GetSQLiteQueries().GetContentVersion(context.Background(), req.VersionID) case "postgresql": targetVersion, err = h.database.GetPostgreSQLQueries().GetContentVersion(context.Background(), int32(req.VersionID)) default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } if err != nil { if err == sql.ErrNoRows { http.Error(w, "Version not found", http.StatusNotFound) return } http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) return } // Verify the version belongs to the correct content if !h.versionMatches(targetVersion, contentID, siteID) { http.Error(w, "Version does not match content", http.StatusBadRequest) return } // 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{} switch h.database.GetDBType() { case "sqlite3": currentContent, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{ ID: contentID, SiteID: siteID, }) case "postgresql": currentContent, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{ ID: contentID, SiteID: siteID, }) default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } if err != nil { http.Error(w, fmt.Sprintf("Failed to get current content: %v", err), http.StatusInternalServerError) return } err = h.createContentVersion(currentContent) if err != nil { http.Error(w, fmt.Sprintf("Failed to create version: %v", err), http.StatusInternalServerError) return } // Rollback to target version var updatedContent interface{} switch h.database.GetDBType() { case "sqlite3": sqliteVersion := targetVersion.(sqlite.ContentVersion) updatedContent, err = h.database.GetSQLiteQueries().UpdateContent(context.Background(), sqlite.UpdateContentParams{ HtmlContent: sqliteVersion.HtmlContent, LastEditedBy: userID, ID: contentID, SiteID: siteID, }) case "postgresql": pgVersion := targetVersion.(postgresql.ContentVersion) updatedContent, err = h.database.GetPostgreSQLQueries().UpdateContent(context.Background(), postgresql.UpdateContentParams{ HtmlContent: pgVersion.HtmlContent, 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 rollback content: %v", err), http.StatusInternalServerError) return } item := h.convertToAPIContent(updatedContent) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(item) } // Helper functions for type conversion func (h *ContentHandler) convertToAPIContent(content interface{}) ContentItem { switch h.database.GetDBType() { case "sqlite3": c := content.(sqlite.Content) return ContentItem{ ID: c.ID, SiteID: c.SiteID, HTMLContent: c.HtmlContent, OriginalTemplate: engine.FromNullString(c.OriginalTemplate), CreatedAt: time.Unix(c.CreatedAt, 0), UpdatedAt: time.Unix(c.UpdatedAt, 0), LastEditedBy: c.LastEditedBy, } case "postgresql": c := content.(postgresql.Content) return ContentItem{ ID: c.ID, SiteID: c.SiteID, HTMLContent: c.HtmlContent, OriginalTemplate: engine.FromNullString(c.OriginalTemplate), CreatedAt: time.Unix(c.CreatedAt, 0), UpdatedAt: time.Unix(c.UpdatedAt, 0), LastEditedBy: c.LastEditedBy, } } return ContentItem{} // Should never happen } func (h *ContentHandler) convertToAPIContentList(contentList interface{}) []ContentItem { switch h.database.GetDBType() { case "sqlite3": list := contentList.([]sqlite.Content) items := make([]ContentItem, len(list)) for i, content := range list { items[i] = h.convertToAPIContent(content) } return items case "postgresql": list := contentList.([]postgresql.Content) items := make([]ContentItem, len(list)) for i, content := range list { items[i] = h.convertToAPIContent(content) } return items } return []ContentItem{} // Should never happen } func (h *ContentHandler) convertToAPIVersionList(versionList interface{}) []ContentVersion { switch h.database.GetDBType() { case "sqlite3": list := versionList.([]sqlite.ContentVersion) versions := make([]ContentVersion, len(list)) for i, version := range list { versions[i] = ContentVersion{ VersionID: version.VersionID, ContentID: version.ContentID, SiteID: version.SiteID, HTMLContent: version.HtmlContent, OriginalTemplate: engine.FromNullString(version.OriginalTemplate), CreatedAt: time.Unix(version.CreatedAt, 0), CreatedBy: version.CreatedBy, } } return versions case "postgresql": list := versionList.([]postgresql.ContentVersion) versions := make([]ContentVersion, len(list)) for i, version := range list { versions[i] = ContentVersion{ VersionID: int64(version.VersionID), ContentID: version.ContentID, SiteID: version.SiteID, HTMLContent: version.HtmlContent, OriginalTemplate: engine.FromNullString(version.OriginalTemplate), CreatedAt: time.Unix(version.CreatedAt, 0), CreatedBy: version.CreatedBy, } } return versions } return []ContentVersion{} // Should never happen } func (h *ContentHandler) createContentVersion(content interface{}) error { switch h.database.GetDBType() { case "sqlite3": c := content.(sqlite.Content) return h.database.GetSQLiteQueries().CreateContentVersion(context.Background(), sqlite.CreateContentVersionParams{ ContentID: c.ID, SiteID: c.SiteID, HtmlContent: c.HtmlContent, OriginalTemplate: c.OriginalTemplate, CreatedBy: c.LastEditedBy, }) case "postgresql": c := content.(postgresql.Content) return h.database.GetPostgreSQLQueries().CreateContentVersion(context.Background(), postgresql.CreateContentVersionParams{ ContentID: c.ID, SiteID: c.SiteID, HtmlContent: c.HtmlContent, OriginalTemplate: c.OriginalTemplate, CreatedBy: c.LastEditedBy, }) } return fmt.Errorf("unsupported database type") } func (h *ContentHandler) versionMatches(version interface{}, contentID, siteID string) bool { switch h.database.GetDBType() { case "sqlite3": v := version.(sqlite.ContentVersion) return v.ContentID == contentID && v.SiteID == siteID case "postgresql": v := version.(postgresql.ContentVersion) return v.ContentID == contentID && v.SiteID == siteID } return false } // generateContentID function removed - using unified ContentEngine instead // ServeInsertrJS handles GET /insertr.js - serves the insertr JavaScript library func (h *ContentHandler) ServeInsertrJS(w http.ResponseWriter, r *http.Request) { // Path to the built insertr.js file jsPath := "lib/dist/insertr.js" // Check if file exists if _, err := os.Stat(jsPath); os.IsNotExist(err) { http.Error(w, "insertr.js not found - run 'just build-lib' to build the library", http.StatusNotFound) return } // Open and serve the file file, err := os.Open(jsPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to open insertr.js: %v", err), http.StatusInternalServerError) return } defer file.Close() // Set appropriate headers w.Header().Set("Content-Type", "application/javascript") w.Header().Set("Cache-Control", "no-cache") // For development // Copy file contents to response io.Copy(w, file) } // ServeInsertrCSS handles GET /insertr.css - serves the insertr CSS stylesheet func (h *ContentHandler) ServeInsertrCSS(w http.ResponseWriter, r *http.Request) { // Path to the built insertr.css file cssPath := "lib/dist/insertr.css" // Check if file exists if _, err := os.Stat(cssPath); os.IsNotExist(err) { http.Error(w, "insertr.css not found - run 'just build-lib' to build the library", http.StatusNotFound) return } // Open and serve the file file, err := os.Open(cssPath) if err != nil { http.Error(w, fmt.Sprintf("Failed to open insertr.css: %v", err), http.StatusInternalServerError) return } defer file.Close() // Set appropriate headers w.Header().Set("Content-Type", "text/css") w.Header().Set("Cache-Control", "no-cache") // For development // Copy file contents to response io.Copy(w, file) } // Collection API handlers // GetCollection handles GET /api/collections/{id} func (h *ContentHandler) GetCollection(w http.ResponseWriter, r *http.Request) { collectionID := chi.URLParam(r, "id") siteID := r.URL.Query().Get("site_id") if siteID == "" { http.Error(w, "site_id parameter is required", http.StatusBadRequest) return } var collection interface{} var err error switch h.database.GetDBType() { case "sqlite3": collection, err = h.database.GetSQLiteQueries().GetCollection(context.Background(), sqlite.GetCollectionParams{ ID: collectionID, SiteID: siteID, }) case "postgresql": collection, err = h.database.GetPostgreSQLQueries().GetCollection(context.Background(), postgresql.GetCollectionParams{ ID: collectionID, SiteID: siteID, }) default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } if err != nil { if err == sql.ErrNoRows { http.Error(w, "Collection not found", http.StatusNotFound) return } http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) return } apiCollection := h.convertToAPICollection(collection) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(apiCollection) } // GetAllCollections handles GET /api/collections func (h *ContentHandler) GetAllCollections(w http.ResponseWriter, r *http.Request) { siteID := r.URL.Query().Get("site_id") if siteID == "" { http.Error(w, "site_id parameter is required", http.StatusBadRequest) return } var collections interface{} var err error switch h.database.GetDBType() { case "sqlite3": collections, err = h.database.GetSQLiteQueries().GetAllCollections(context.Background(), siteID) case "postgresql": collections, err = h.database.GetPostgreSQLQueries().GetAllCollections(context.Background(), siteID) default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } if err != nil { http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) return } apiCollections := h.convertToAPICollectionList(collections) response := CollectionResponse{Collections: apiCollections} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // GetCollectionItems handles GET /api/collections/{id}/items func (h *ContentHandler) GetCollectionItems(w http.ResponseWriter, r *http.Request) { collectionID := chi.URLParam(r, "id") siteID := r.URL.Query().Get("site_id") if siteID == "" { http.Error(w, "site_id parameter is required", http.StatusBadRequest) return } var items interface{} var err error switch h.database.GetDBType() { case "sqlite3": items, err = h.database.GetSQLiteQueries().GetCollectionItemsWithTemplate(context.Background(), sqlite.GetCollectionItemsWithTemplateParams{ CollectionID: collectionID, SiteID: siteID, }) case "postgresql": items, err = h.database.GetPostgreSQLQueries().GetCollectionItemsWithTemplate(context.Background(), postgresql.GetCollectionItemsWithTemplateParams{ CollectionID: collectionID, SiteID: siteID, }) default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } if err != nil { http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) return } apiItems := h.convertToAPICollectionItemList(items) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "items": apiItems, }) } // CreateCollectionItem handles POST /api/collections/{id}/items func (h *ContentHandler) CreateCollectionItem(w http.ResponseWriter, r *http.Request) { collectionID := chi.URLParam(r, "id") var req CreateCollectionItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } // Set defaults if req.SiteID == "" { req.SiteID = r.URL.Query().Get("site_id") } if req.SiteID == "" { http.Error(w, "site_id is required", http.StatusBadRequest) return } if req.CreatedBy == "" { req.CreatedBy = "api" } if req.CollectionID == "" { req.CollectionID = collectionID } if req.TemplateID == 0 { req.TemplateID = 1 // Default to first template } // Create database client for atomic operations dbClient := engine.NewDatabaseClient(h.database) // Use atomic collection item creation createdItem, err := dbClient.CreateCollectionItemAtomic( req.SiteID, req.CollectionID, req.TemplateID, req.CreatedBy, ) if err != nil { http.Error(w, fmt.Sprintf("Failed to create collection item: %v", err), http.StatusInternalServerError) return } // Convert to API response format apiItem := CollectionItemData{ ItemID: createdItem.ItemID, CollectionID: createdItem.CollectionID, SiteID: createdItem.SiteID, TemplateID: createdItem.TemplateID, HTMLContent: createdItem.HTMLContent, Position: createdItem.Position, CreatedAt: time.Now(), UpdatedAt: time.Now(), LastEditedBy: createdItem.LastEditedBy, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(apiItem) } // UpdateCollectionItem handles PUT /api/collections/{id}/items/{item_id} func (h *ContentHandler) UpdateCollectionItem(w http.ResponseWriter, r *http.Request) { collectionID := chi.URLParam(r, "id") itemID := chi.URLParam(r, "item_id") siteID := r.URL.Query().Get("site_id") if siteID == "" { http.Error(w, "site_id parameter is required", http.StatusBadRequest) return } var req UpdateCollectionItemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } if req.UpdatedBy == "" { req.UpdatedBy = "api" } var updatedItem interface{} var err error // Validate position bounds if position update is requested if req.Position > 0 { var maxPos int64 switch h.database.GetDBType() { case "sqlite3": result, err := h.database.GetSQLiteQueries().GetMaxPosition(context.Background(), sqlite.GetMaxPositionParams{ CollectionID: collectionID, SiteID: siteID, }) if err != nil { http.Error(w, "Failed to get max position", http.StatusInternalServerError) return } // Convert interface{} to int64 switch v := result.(type) { case int64: maxPos = v case int: maxPos = int64(v) default: maxPos = 0 } case "postgresql": result, err := h.database.GetPostgreSQLQueries().GetMaxPosition(context.Background(), postgresql.GetMaxPositionParams{ CollectionID: collectionID, SiteID: siteID, }) if err != nil { http.Error(w, "Failed to get max position", http.StatusInternalServerError) return } // Convert interface{} to int64 switch v := result.(type) { case int64: maxPos = v case int32: maxPos = int64(v) case int: maxPos = int64(v) default: maxPos = 0 } } // Check if position is valid (1-based, within bounds) if int64(req.Position) > maxPos { http.Error(w, fmt.Sprintf("Invalid position: %d exceeds max position %d", req.Position, maxPos), http.StatusBadRequest) return } } switch h.database.GetDBType() { case "sqlite3": // Update position if provided if req.Position > 0 { err = h.database.GetSQLiteQueries().UpdateCollectionItemPosition(context.Background(), sqlite.UpdateCollectionItemPositionParams{ ItemID: itemID, CollectionID: collectionID, SiteID: siteID, Position: int64(req.Position), }) if err != nil { http.Error(w, fmt.Sprintf("Failed to update position: %v", err), http.StatusInternalServerError) return } } // If only position update (no html_content), just get the updated item if req.HTMLContent == "" { updatedItem, err = h.database.GetSQLiteQueries().GetCollectionItem(context.Background(), sqlite.GetCollectionItemParams{ ItemID: itemID, CollectionID: collectionID, SiteID: siteID, }) } else { // Update content and metadata updatedItem, err = h.database.GetSQLiteQueries().UpdateCollectionItem(context.Background(), sqlite.UpdateCollectionItemParams{ ItemID: itemID, CollectionID: collectionID, SiteID: siteID, HtmlContent: req.HTMLContent, LastEditedBy: req.UpdatedBy, }) } case "postgresql": // Update position if provided if req.Position > 0 { err = h.database.GetPostgreSQLQueries().UpdateCollectionItemPosition(context.Background(), postgresql.UpdateCollectionItemPositionParams{ ItemID: itemID, CollectionID: collectionID, SiteID: siteID, Position: int32(req.Position), }) if err != nil { http.Error(w, fmt.Sprintf("Failed to update position: %v", err), http.StatusInternalServerError) return } } // If only position update (no html_content), just get the updated item if req.HTMLContent == "" { updatedItem, err = h.database.GetPostgreSQLQueries().GetCollectionItem(context.Background(), postgresql.GetCollectionItemParams{ ItemID: itemID, CollectionID: collectionID, SiteID: siteID, }) } else { // Update content and metadata updatedItem, err = h.database.GetPostgreSQLQueries().UpdateCollectionItem(context.Background(), postgresql.UpdateCollectionItemParams{ ItemID: itemID, CollectionID: collectionID, SiteID: siteID, HtmlContent: req.HTMLContent, LastEditedBy: req.UpdatedBy, }) } default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } if err != nil { if err == sql.ErrNoRows { http.Error(w, "Collection item not found", http.StatusNotFound) return } http.Error(w, fmt.Sprintf("Failed to update collection item: %v", err), http.StatusInternalServerError) return } apiItem := h.convertToAPICollectionItem(updatedItem) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(apiItem) } // DeleteCollectionItem handles DELETE /api/collections/{id}/items/{item_id} func (h *ContentHandler) DeleteCollectionItem(w http.ResponseWriter, r *http.Request) { collectionID := chi.URLParam(r, "id") itemID := chi.URLParam(r, "item_id") siteID := r.URL.Query().Get("site_id") if siteID == "" { http.Error(w, "site_id parameter is required", http.StatusBadRequest) return } var err error switch h.database.GetDBType() { case "sqlite3": err = h.database.GetSQLiteQueries().DeleteCollectionItem(context.Background(), sqlite.DeleteCollectionItemParams{ ItemID: itemID, CollectionID: collectionID, SiteID: siteID, }) case "postgresql": err = h.database.GetPostgreSQLQueries().DeleteCollectionItem(context.Background(), postgresql.DeleteCollectionItemParams{ ItemID: itemID, CollectionID: collectionID, SiteID: siteID, }) default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } if err != nil { http.Error(w, fmt.Sprintf("Failed to delete collection item: %v", err), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } // ReorderCollection handles PUT /api/collections/{id}/reorder func (h *ContentHandler) ReorderCollection(w http.ResponseWriter, r *http.Request) { collectionID := chi.URLParam(r, "id") siteID := r.URL.Query().Get("site_id") if siteID == "" { http.Error(w, "site_id parameter is required", http.StatusBadRequest) return } var req ReorderCollectionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } if req.UpdatedBy == "" { req.UpdatedBy = "api" } // Validate that all items belong to the collection and have valid positions if len(req.Items) == 0 { http.Error(w, "Items array cannot be empty", http.StatusBadRequest) return } // Update positions for all items in a transaction for atomicity switch h.database.GetDBType() { case "sqlite3": // Use transaction for atomic bulk updates tx, err := h.database.GetSQLiteDB().Begin() if err != nil { http.Error(w, "Failed to begin transaction", http.StatusInternalServerError) return } defer tx.Rollback() // Create queries with transaction context qtx := h.database.GetSQLiteQueries().WithTx(tx) for _, item := range req.Items { err = qtx.UpdateCollectionItemPosition(context.Background(), sqlite.UpdateCollectionItemPositionParams{ ItemID: item.ItemID, CollectionID: collectionID, SiteID: siteID, Position: int64(item.Position), LastEditedBy: req.UpdatedBy, }) if err != nil { http.Error(w, fmt.Sprintf("Failed to update position for item %s: %v", item.ItemID, err), http.StatusInternalServerError) return } } // Commit transaction if err = tx.Commit(); err != nil { http.Error(w, "Failed to commit bulk reorder transaction", http.StatusInternalServerError) return } case "postgres": // Use transaction for atomic bulk updates tx, err := h.database.GetPostgreSQLDB().Begin() if err != nil { http.Error(w, "Failed to begin transaction", http.StatusInternalServerError) return } defer tx.Rollback() // Create queries with transaction context qtx := h.database.GetPostgreSQLQueries().WithTx(tx) for _, item := range req.Items { err = qtx.UpdateCollectionItemPosition(context.Background(), postgresql.UpdateCollectionItemPositionParams{ ItemID: item.ItemID, CollectionID: collectionID, SiteID: siteID, Position: int32(item.Position), LastEditedBy: req.UpdatedBy, }) if err != nil { http.Error(w, fmt.Sprintf("Failed to update position for item %s: %v", item.ItemID, err), http.StatusInternalServerError) return } } // Commit transaction if err = tx.Commit(); err != nil { http.Error(w, "Failed to commit bulk reorder transaction", http.StatusInternalServerError) return } default: http.Error(w, "Unsupported database type", http.StatusInternalServerError) return } // Return success response w.Header().Set("Content-Type", "application/json") response := map[string]interface{}{ "success": true, "message": fmt.Sprintf("Successfully reordered %d items", len(req.Items)), } json.NewEncoder(w).Encode(response) } // Collection conversion helpers // convertToAPICollection converts database collection to API model func (h *ContentHandler) convertToAPICollection(dbCollection interface{}) CollectionItem { switch h.database.GetDBType() { case "sqlite3": collection := dbCollection.(sqlite.Collection) return CollectionItem{ ID: collection.ID, SiteID: collection.SiteID, ContainerHTML: collection.ContainerHtml, CreatedAt: time.Unix(collection.CreatedAt, 0), UpdatedAt: time.Unix(collection.UpdatedAt, 0), LastEditedBy: collection.LastEditedBy, } case "postgresql": collection := dbCollection.(postgresql.Collection) return CollectionItem{ ID: collection.ID, SiteID: collection.SiteID, ContainerHTML: collection.ContainerHtml, CreatedAt: time.Unix(collection.CreatedAt, 0), UpdatedAt: time.Unix(collection.UpdatedAt, 0), LastEditedBy: collection.LastEditedBy, } default: return CollectionItem{} } } // convertToAPICollectionList converts database collection list to API models func (h *ContentHandler) convertToAPICollectionList(dbCollections interface{}) []CollectionItem { switch h.database.GetDBType() { case "sqlite3": collections := dbCollections.([]sqlite.Collection) result := make([]CollectionItem, len(collections)) for i, collection := range collections { result[i] = CollectionItem{ ID: collection.ID, SiteID: collection.SiteID, ContainerHTML: collection.ContainerHtml, CreatedAt: time.Unix(collection.CreatedAt, 0), UpdatedAt: time.Unix(collection.UpdatedAt, 0), LastEditedBy: collection.LastEditedBy, } } return result case "postgresql": collections := dbCollections.([]postgresql.Collection) result := make([]CollectionItem, len(collections)) for i, collection := range collections { result[i] = CollectionItem{ ID: collection.ID, SiteID: collection.SiteID, ContainerHTML: collection.ContainerHtml, CreatedAt: time.Unix(collection.CreatedAt, 0), UpdatedAt: time.Unix(collection.UpdatedAt, 0), LastEditedBy: collection.LastEditedBy, } } return result default: return []CollectionItem{} } } // convertToAPICollectionItem converts database collection item to API model func (h *ContentHandler) convertToAPICollectionItem(dbItem interface{}) CollectionItemData { switch h.database.GetDBType() { case "sqlite3": item := dbItem.(sqlite.CollectionItem) return CollectionItemData{ ItemID: item.ItemID, CollectionID: item.CollectionID, SiteID: item.SiteID, TemplateID: int(item.TemplateID), HTMLContent: item.HtmlContent, Position: int(item.Position), CreatedAt: time.Unix(item.CreatedAt, 0), UpdatedAt: time.Unix(item.UpdatedAt, 0), LastEditedBy: item.LastEditedBy, } case "postgresql": item := dbItem.(postgresql.CollectionItem) return CollectionItemData{ ItemID: item.ItemID, CollectionID: item.CollectionID, SiteID: item.SiteID, TemplateID: int(item.TemplateID), HTMLContent: item.HtmlContent, Position: int(item.Position), CreatedAt: time.Unix(item.CreatedAt, 0), UpdatedAt: time.Unix(item.UpdatedAt, 0), LastEditedBy: item.LastEditedBy, } default: return CollectionItemData{} } } // convertToAPICollectionItemList converts database collection item list to API models func (h *ContentHandler) convertToAPICollectionItemList(dbItems interface{}) []CollectionItemData { switch h.database.GetDBType() { case "sqlite3": items := dbItems.([]sqlite.GetCollectionItemsWithTemplateRow) result := make([]CollectionItemData, len(items)) for i, item := range items { result[i] = CollectionItemData{ ItemID: item.ItemID, CollectionID: item.CollectionID, SiteID: item.SiteID, TemplateID: int(item.TemplateID), HTMLContent: item.HtmlContent, Position: int(item.Position), CreatedAt: time.Unix(item.CreatedAt, 0), UpdatedAt: time.Unix(item.UpdatedAt, 0), LastEditedBy: item.LastEditedBy, TemplateName: item.TemplateName, } } return result case "postgresql": items := dbItems.([]postgresql.GetCollectionItemsWithTemplateRow) result := make([]CollectionItemData, len(items)) for i, item := range items { result[i] = CollectionItemData{ ItemID: item.ItemID, CollectionID: item.CollectionID, SiteID: item.SiteID, TemplateID: int(item.TemplateID), HTMLContent: item.HtmlContent, Position: int(item.Position), CreatedAt: time.Unix(item.CreatedAt, 0), UpdatedAt: time.Unix(item.UpdatedAt, 0), LastEditedBy: item.LastEditedBy, TemplateName: item.TemplateName, } } return result default: return []CollectionItemData{} } }