readysite / website / controllers / pages.go
12.1 KB
pages.go
package controllers

import (
	"encoding/json"
	"fmt"
	"net/http"

	"github.com/readysite/readysite/pkg/application"
	"github.com/readysite/readysite/website/internal/access"
	"github.com/readysite/readysite/website/internal/helpers"
	"github.com/readysite/readysite/website/internal/content"
	"github.com/readysite/readysite/website/models"
)

// Pages returns the pages controller.
func Pages() (string, *PagesController) {
	return "pages", &PagesController{}
}

// PagesController handles page CRUD operations.
type PagesController struct {
	application.BaseController
}

// Setup registers routes.
func (c *PagesController) Setup(app *application.App) {
	c.BaseController.Setup(app)
}

// Handle implements Controller interface with value receiver for request isolation.
func (c PagesController) Handle(r *http.Request) application.Controller {
	c.Request = r
	return &c
}

// PageTreeNode represents a page with its children for tree rendering.
type PageTreeNode = content.TreeNode

// Page returns the current page from the path parameter.
func (c *PagesController) Page() *models.Page {
	if c.Request == nil {
		return nil
	}
	id := c.PathValue("id")
	if id == "" {
		return nil
	}
	page, err := models.Pages.Get(id)
	if err != nil {
		return nil
	}
	return page
}

// RootPages returns top-level pages.
func (c *PagesController) RootPages() []*models.Page {
	pages, _ := models.Pages.Search("WHERE ParentID = '' ORDER BY Position, CreatedAt")
	return pages
}

// PageTree returns the nested page tree structure.
// Uses single query and builds tree in memory to avoid N+1 queries.
func (c *PagesController) PageTree() []*PageTreeNode {
	return content.BuildTree()
}

// AllPages returns all pages for selection dropdowns.
func (c *PagesController) AllPages() []*models.Page {
	pages, _ := models.Pages.Search("ORDER BY Position, ID")
	return pages
}

// IsNew returns true if creating a new page.
func (c *PagesController) IsNew() bool {
	if c.Request == nil {
		return true
	}
	id := c.PathValue("id")
	return id == ""
}

// Create creates a new page.
func (c *PagesController) Create(w http.ResponseWriter, r *http.Request) {
	slug := r.FormValue("slug")
	title := r.FormValue("title")
	description := r.FormValue("description")
	html := r.FormValue("html")

	// Validate slug if provided
	if err := content.ValidateSlug(slug); err != nil {
		c.RenderError(w, r, err)
		return
	}
	if err := content.CheckSlugAvailable(slug, "page"); err != nil {
		c.RenderError(w, r, err)
		return
	}

	// Validate page input fields
	if err := content.ValidatePageInput(title, description, html); err != nil {
		c.RenderError(w, r, err)
		return
	}

	page := &models.Page{
		ParentID: r.FormValue("parent_id"),
	}

	// Use slug as ID if provided
	if slug != "" {
		page.ID = slug
	}

	id, err := models.Pages.Insert(page)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("Failed to create page: %w", err))
		return
	}

	// Get current user ID for versioning
	userID := ""
	if user := access.GetUserFromJWT(r); user != nil {
		userID = user.ID
	}

	// Determine content status based on form checkbox
	contentStatus := models.StatusDraft
	if r.FormValue("published") == "on" {
		contentStatus = models.StatusPublished
	}

	// Save content as first version with appropriate status
	page.ID = id
	_, err = content.SavePageContent(
		page,
		title,
		description,
		html,
		userID,
		contentStatus,
	)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("Failed to save page content: %w", err))
		return
	}

	// Audit log
	helpers.AuditCreate(r, userID, helpers.ResourcePage, id, r.FormValue("title"))

	w.Header().Set("HX-Trigger", `{"showToast":"Page created"}`)
	c.Refresh(w, r)
}

// Update updates an existing page.
func (c *PagesController) Update(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	title := r.FormValue("title")
	description := r.FormValue("description")
	html := r.FormValue("html")

	// Validate page input fields
	if err := content.ValidatePageInput(title, description, html); err != nil {
		c.RenderError(w, r, err)
		return
	}

	page, err := models.Pages.Get(id)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("Page not found"))
		return
	}

	// Update page metadata
	page.ParentID = r.FormValue("parent_id")

	if err := models.Pages.Update(page); err != nil {
		c.RenderError(w, r, fmt.Errorf("Failed to update page: %w", err))
		return
	}

	// Get current user ID for versioning
	userID := ""
	if user := access.GetUserFromJWT(r); user != nil {
		userID = user.ID
	}

	// Determine content status based on form checkbox
	contentStatus := models.StatusDraft
	if r.FormValue("published") == "on" {
		contentStatus = models.StatusPublished
	}

	// Save content as new version with appropriate status
	_, err = content.SavePageContent(
		page,
		title,
		description,
		html,
		userID,
		contentStatus,
	)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("Failed to save page content: %w", err))
		return
	}

	// Audit log
	helpers.AuditUpdate(r, userID, helpers.ResourcePage, id, r.FormValue("title"), nil)

	w.Header().Set("HX-Trigger", `{"showToast":"Page saved"}`)
	c.Refresh(w, r)
}

// Delete deletes a page.
func (c *PagesController) Delete(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	page, err := models.Pages.Get(id)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("Page not found"))
		return
	}

	// Get page title before deletion
	title := ""
	if content := page.LatestContent(); content != nil {
		title = content.Title
	}

	if err := models.Pages.Delete(page); err != nil {
		c.RenderError(w, r, fmt.Errorf("Failed to delete page: %w", err))
		return
	}

	// Audit log
	userID := ""
	if user := access.GetUserFromJWT(r); user != nil {
		userID = user.ID
	}
	helpers.AuditDelete(r, userID, helpers.ResourcePage, id, title)

	c.Redirect(w, r, "/admin/pages")
}

// TogglePublish toggles the published status of a page's latest content.
func (c *PagesController) TogglePublish(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	page, err := models.Pages.Get(id)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("Page not found"))
		return
	}

	// Get latest content
	content := page.LatestContent()
	if content == nil {
		c.RenderError(w, r, fmt.Errorf("Page has no content"))
		return
	}

	// Toggle status
	if content.IsPublished() {
		content.Status = models.StatusDraft
	} else {
		content.Status = models.StatusPublished
	}

	if err := models.PageContents.Update(content); err != nil {
		c.RenderError(w, r, fmt.Errorf("Failed to update page status: %w", err))
		return
	}

	c.Refresh(w, r)
}

// Reorder updates page positions based on the new order.
func (c *PagesController) Reorder(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		c.RenderError(w, r, fmt.Errorf("Invalid form data: %w", err))
		return
	}

	pageIDs := r.Form["page_ids"]
	if len(pageIDs) == 0 {
		c.RenderError(w, r, fmt.Errorf("No pages to reorder"))
		return
	}

	var errors []string
	for i, id := range pageIDs {
		page, err := models.Pages.Get(id)
		if err != nil {
			errors = append(errors, fmt.Sprintf("page %s not found", id))
			continue
		}
		page.Position = i
		if err := models.Pages.Update(page); err != nil {
			errors = append(errors, fmt.Sprintf("failed to update %s: %v", id, err))
		}
	}

	if len(errors) > 0 {
		c.RenderError(w, r, fmt.Errorf("Some pages failed to reorder: %v", errors))
		return
	}

	w.WriteHeader(http.StatusOK)
}

// PageContents returns all content versions for the current page.
func (c *PagesController) PageContents() []*models.PageContent {
	page := c.Page()
	if page == nil {
		return nil
	}
	contents, _ := page.Contents()
	return contents
}

// ContentCreator returns the user who created a content version.
func (c *PagesController) ContentCreator(content *models.PageContent) *models.User {
	if content == nil || content.CreatedBy == "" {
		return nil
	}
	user, _ := models.Users.Get(content.CreatedBy)
	return user
}

// LatestContentID returns the current content version ID for the page.
func (c *PagesController) LatestContentID() string {
	page := c.Page()
	if page == nil {
		return ""
	}
	if content := page.LatestContent(); content != nil {
		return content.ID
	}
	return ""
}

// SaveCode saves page HTML with version conflict checking.
func (c *PagesController) SaveCode(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	page, err := models.Pages.Get(id)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("Page not found"))
		return
	}

	// Check for version conflict
	expectedVersion := r.FormValue("version_id")
	currentContent := page.LatestContent()
	if currentContent != nil && expectedVersion != "" && currentContent.ID != expectedVersion {
		// Version conflict - someone else edited the page
		w.Header().Set("HX-Retarget", "#code-save-error")
		w.Header().Set("HX-Reswap", "innerHTML")
		w.WriteHeader(http.StatusConflict)
		fmt.Fprintf(w, `<div class="alert alert-warning text-sm">
			<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
				<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
			</svg>
			<span>This page was modified. <a href="" class="link" onclick="location.reload(); return false;">Reload</a> to see changes.</span>
		</div>`)
		return
	}

	// Get current user ID for versioning
	userID := ""
	if user := access.GetUserFromJWT(r); user != nil {
		userID = user.ID
	}

	// Get current title and description to preserve them
	title := ""
	description := ""
	if currentContent != nil {
		title = currentContent.Title
		description = currentContent.Description
	}

	// Save new content version
	newContent, err := content.SavePageContent(page, title, description, r.FormValue("html"), userID)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("Failed to save: %w", err))
		return
	}

	// Return success with new version ID
	w.Header().Set("HX-Trigger", "codeSaved")
	w.Header().Set("Content-Type", "text/html")
	fmt.Fprintf(w, `<input type="hidden" name="version_id" id="code-version-id" value="%s" hx-swap-oob="true" />`, newContent.ID)
}

// Restore restores a page to a previous content version.
func (c *PagesController) Restore(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	contentID := r.PathValue("contentId")

	page, err := models.Pages.Get(id)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("Page not found"))
		return
	}

	oldContent, err := models.PageContents.Get(contentID)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("Content version not found"))
		return
	}

	// Create new content version with old content
	userID := ""
	if user := access.GetUserFromJWT(r); user != nil {
		userID = user.ID
	}

	_, err = content.SavePageContent(page, oldContent.Title, oldContent.Description, oldContent.HTML, userID)
	if err != nil {
		c.RenderError(w, r, fmt.Errorf("Failed to restore content: %w", err))
		return
	}

	c.Redirect(w, r, "/admin/page/"+id)
}

// GetLatestContent returns the latest content version for a page as JSON.
func (c *PagesController) GetLatestContent(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")

	page, err := models.Pages.Get(id)
	if err != nil {
		http.Error(w, "Page not found", http.StatusNotFound)
		return
	}

	content := page.LatestContent()
	if content == nil {
		w.Header().Set("Content-Type", "application/json")
		w.Write([]byte(`{"html":""}`))
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"id":          content.ID,
		"title":       content.Title,
		"description": content.Description,
		"html":        content.HTML,
		"createdAt":   content.CreatedAt,
	})
}

// GetContent returns a specific content version for a page as JSON.
func (c *PagesController) GetContent(w http.ResponseWriter, r *http.Request) {
	contentID := r.PathValue("contentId")

	content, err := models.PageContents.Get(contentID)
	if err != nil {
		http.Error(w, "Content version not found", http.StatusNotFound)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]any{
		"id":          content.ID,
		"title":       content.Title,
		"description": content.Description,
		"html":        content.HTML,
		"createdAt":   content.CreatedAt,
	})
}
← Back