readysite / website / controllers / admin_api.go
46.0 KB
admin_api.go
package controllers

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

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

// AdminAPI returns the admin API controller for JSON endpoints.
func AdminAPI() (string, *AdminAPIController) {
	return "adminapi", &AdminAPIController{}
}

// AdminAPIController handles admin JSON API endpoints.
type AdminAPIController struct {
	application.BaseController
}

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

	// Pages API
	http.Handle("GET /api/admin/pages", app.Method(c, "ListPages", RequireAuthAPI))
	http.Handle("GET /api/admin/pages/{id}", app.Method(c, "GetPage", RequireAuthAPI))
	http.Handle("POST /api/admin/pages", app.Method(c, "CreatePage", RequireAuthAPI))
	http.Handle("PUT /api/admin/pages/{id}", app.Method(c, "UpdatePage", RequireAuthAPI))
	http.Handle("PATCH /api/admin/pages/{id}", app.Method(c, "UpdatePage", RequireAuthAPI))
	http.Handle("DELETE /api/admin/pages/{id}", app.Method(c, "DeletePage", RequireAuthAPI))

	// Page versioning API
	http.Handle("GET /api/admin/pages/{id}/versions", app.Method(c, "ListPageVersions", RequireAuthAPI))
	http.Handle("GET /api/admin/pages/{id}/content/{contentId}", app.Method(c, "GetPageContent", RequireAuthAPI))
	http.Handle("GET /api/admin/pages/{id}/content/latest", app.Method(c, "GetLatestPageContent", RequireAuthAPI))
	http.Handle("POST /api/admin/pages/{id}/code", app.Method(c, "UpdatePageCode", RequireAuthAPI))
	http.Handle("POST /api/admin/pages/{id}/publish", app.Method(c, "PublishPage", RequireAuthAPI))
	http.Handle("POST /api/admin/pages/{id}/restore/{contentId}", app.Method(c, "RestorePageVersion", RequireAuthAPI))

	// Collections API
	http.Handle("GET /api/admin/collections", app.Method(c, "ListCollections", RequireAuthAPI))
	http.Handle("GET /api/admin/collections/{id}", app.Method(c, "GetCollection", RequireAuthAPI))
	http.Handle("POST /api/admin/collections", app.Method(c, "CreateCollection", RequireAuthAPI))
	http.Handle("PUT /api/admin/collections/{id}", app.Method(c, "UpdateCollection", RequireAuthAPI))
	http.Handle("PATCH /api/admin/collections/{id}", app.Method(c, "UpdateCollection", RequireAuthAPI))
	http.Handle("DELETE /api/admin/collections/{id}", app.Method(c, "DeleteCollection", RequireAuthAPI))

	// Collection documents API
	http.Handle("GET /api/admin/collections/{id}/documents", app.Method(c, "ListDocuments", RequireAuthAPI))
	http.Handle("GET /api/admin/collections/{id}/documents/{docId}", app.Method(c, "GetDocument", RequireAuthAPI))
	http.Handle("POST /api/admin/collections/{id}/documents", app.Method(c, "CreateDocument", RequireAuthAPI))
	http.Handle("PUT /api/admin/collections/{id}/documents/{docId}", app.Method(c, "UpdateDocument", RequireAuthAPI))
	http.Handle("PATCH /api/admin/collections/{id}/documents/{docId}", app.Method(c, "UpdateDocument", RequireAuthAPI))
	http.Handle("DELETE /api/admin/collections/{id}/documents/{docId}", app.Method(c, "DeleteDocument", RequireAuthAPI))

	// Partials API
	http.Handle("GET /api/admin/partials", app.Method(c, "ListPartials", RequireAuthAPI))
	http.Handle("GET /api/admin/partials/{id}", app.Method(c, "GetPartial", RequireAuthAPI))
	http.Handle("POST /api/admin/partials", app.Method(c, "CreatePartial", RequireAuthAPI))
	http.Handle("PUT /api/admin/partials/{id}", app.Method(c, "UpdatePartial", RequireAuthAPI))
	http.Handle("PATCH /api/admin/partials/{id}", app.Method(c, "UpdatePartial", RequireAuthAPI))
	http.Handle("DELETE /api/admin/partials/{id}", app.Method(c, "DeletePartial", RequireAuthAPI))

	// Settings API
	http.Handle("GET /api/admin/settings", app.Method(c, "GetSettings", RequireAuthAPI))
	http.Handle("POST /api/admin/settings", app.Method(c, "UpdateSettings", RequireAuthAPI))
	http.Handle("PATCH /api/admin/settings", app.Method(c, "UpdateSettings", RequireAuthAPI))

	// Backup API (file download, not JSON)
	http.Handle("GET /admin/backup", app.Method(c, "DownloadBackup", RequireAuth))

	// Audit logs API
	http.Handle("GET /api/admin/audit", app.Method(c, "ListAuditLogs", RequireAuthAPI))

	// Admin events SSE (real-time updates)
	http.Handle("GET /api/admin/events/subscribe", app.Method(c, "SubscribeEvents", RequireAuthAPI))

	// Tour API
	http.Handle("POST /api/admin/tour/complete", app.Method(c, "CompleteTour", RequireAuthAPI))

	// Chat/Conversations API (use RequireAuthAPI to return JSON 401 instead of redirect)
	http.Handle("GET /api/admin/conversations", app.Method(c, "ListConversations", RequireAuthAPI))
	http.Handle("GET /api/admin/conversations/{id}", app.Method(c, "GetConversation", RequireAuthAPI))
	http.Handle("POST /api/admin/conversations", app.Method(c, "CreateConversation", RequireAuthAPI))
	http.Handle("PATCH /api/admin/conversations/{id}", app.Method(c, "UpdateConversation", RequireAuthAPI))
	http.Handle("POST /api/admin/conversations/{id}/messages", app.Method(c, "SendMessage", RequireAuthAPI))
	http.Handle("GET /api/admin/conversations/{id}/stream", app.Method(c, "StreamConversation", RequireAuthAPI))
	http.Handle("DELETE /api/admin/conversations/{id}", app.Method(c, "DeleteConversation", RequireAuthAPI))
	http.Handle("POST /api/admin/conversations/{id}/undo", app.Method(c, "UndoConversation", RequireAuthAPI))
	http.Handle("POST /api/admin/conversations/{id}/redo", app.Method(c, "RedoConversation", RequireAuthAPI))
}

// Handle implements Controller interface.
func (c AdminAPIController) Handle(r *http.Request) application.Controller {
	c.Request = r
	return &c
}

// --- Pages API ---

// ListPages returns all pages.
func (c *AdminAPIController) ListPages(w http.ResponseWriter, r *http.Request) {
	pages, err := models.Pages.Search("ORDER BY Position")
	if err != nil {
		c.jsonError(w, "Failed to list pages", http.StatusInternalServerError)
		return
	}

	items := make([]map[string]any, len(pages))
	for i, p := range pages {
		items[i] = pageToJSON(p)
	}

	c.json(w, map[string]any{
		"items":      items,
		"totalItems": len(items),
	})
}

// GetPage returns a single page.
func (c *AdminAPIController) GetPage(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	page, err := models.Pages.Get(id)
	if err != nil {
		c.jsonError(w, "Page not found", http.StatusNotFound)
		return
	}
	c.json(w, pageToJSON(page))
}

// CreatePage creates a new page.
func (c *AdminAPIController) CreatePage(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	if user == nil {
		c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	var req struct {
		Title       string `json:"title"`
		HTML        string `json:"html"`
		Description string `json:"description"`
		ParentID    string `json:"parentId"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	// Create the page
	page := &models.Page{
		ParentID: req.ParentID,
	}

	id, err := models.Pages.Insert(page)
	if err != nil {
		c.jsonError(w, "Failed to create page", http.StatusInternalServerError)
		return
	}

	// Create initial content version
	content := &models.PageContent{
		PageID:      id,
		Title:       req.Title,
		Description: req.Description,
		HTML:        req.HTML,
		CreatedBy:   user.ID,
		Status:      models.StatusDraft,
	}
	if _, err := models.PageContents.Insert(content); err != nil {
		c.jsonError(w, "Failed to create page content", http.StatusInternalServerError)
		return
	}

	page, _ = models.Pages.Get(id)
	c.json(w, pageToJSON(page), http.StatusCreated)
}

// UpdatePage updates a page.
func (c *AdminAPIController) UpdatePage(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	if user == nil {
		c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

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

	var req map[string]any
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	// Update page fields
	if v, ok := req["parentId"].(string); ok {
		page.ParentID = v
		if err := models.Pages.Update(page); err != nil {
			c.jsonError(w, "Failed to update page", http.StatusInternalServerError)
			return
		}
	}

	// Create new content version if content fields changed
	needsNewContent := false
	title := page.Title()
	description := page.Description()
	html := page.HTML()
	status := models.StatusDraft

	if v, ok := req["title"].(string); ok {
		title = v
		needsNewContent = true
	}
	if v, ok := req["description"].(string); ok {
		description = v
		needsNewContent = true
	}
	if v, ok := req["html"].(string); ok {
		html = v
		needsNewContent = true
	}
	if v, ok := req["published"].(bool); ok {
		if v {
			status = models.StatusPublished
		}
		needsNewContent = true
	}

	if needsNewContent {
		content := &models.PageContent{
			PageID:      page.ID,
			Title:       title,
			Description: description,
			HTML:        html,
			CreatedBy:   user.ID,
			Status:      status,
		}
		if _, err := models.PageContents.Insert(content); err != nil {
			c.jsonError(w, "Failed to save content", http.StatusInternalServerError)
			return
		}
	}

	c.json(w, pageToJSON(page))
}

// DeletePage deletes a page.
func (c *AdminAPIController) DeletePage(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	page, err := models.Pages.Get(id)
	if err != nil {
		c.jsonError(w, "Page not found", http.StatusNotFound)
		return
	}

	// Delete all content versions first
	contents, _ := page.Contents()
	for _, content := range contents {
		models.PageContents.Delete(content)
	}

	if err := models.Pages.Delete(page); err != nil {
		c.jsonError(w, "Failed to delete page", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

// --- Page Versioning API ---

// ListPageVersions returns all content versions for a page.
func (c *AdminAPIController) ListPageVersions(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	page, err := models.Pages.Get(id)
	if err != nil {
		c.jsonError(w, "Page not found", http.StatusNotFound)
		return
	}

	contents, err := page.Contents()
	if err != nil {
		c.jsonError(w, "Failed to get versions", http.StatusInternalServerError)
		return
	}

	items := make([]map[string]any, len(contents))
	for i, pc := range contents {
		creator, _ := pc.Creator()
		creatorName := ""
		if creator != nil {
			creatorName = creator.Name
		}
		items[i] = map[string]any{
			"id":          pc.ID,
			"title":       pc.Title,
			"status":      pc.Status,
			"creatorName": creatorName,
			"created":     pc.CreatedAt.Format("2006-01-02T15:04:05Z"),
		}
	}

	c.json(w, map[string]any{
		"items":      items,
		"totalItems": len(items),
	})
}

// GetPageContent returns a specific content version.
func (c *AdminAPIController) GetPageContent(w http.ResponseWriter, r *http.Request) {
	contentId := r.PathValue("contentId")
	content, err := models.PageContents.Get(contentId)
	if err != nil {
		c.jsonError(w, "Content not found", http.StatusNotFound)
		return
	}
	c.json(w, map[string]any{
		"id":          content.ID,
		"title":       content.Title,
		"description": content.Description,
		"html":        content.HTML,
		"status":      content.Status,
		"created":     content.CreatedAt.Format("2006-01-02T15:04:05Z"),
	})
}

// GetLatestPageContent returns the latest content version for a page.
func (c *AdminAPIController) GetLatestPageContent(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	page, err := models.Pages.Get(id)
	if err != nil {
		c.jsonError(w, "Page not found", http.StatusNotFound)
		return
	}
	content := page.LatestContent()
	if content == nil {
		c.jsonError(w, "No content found", http.StatusNotFound)
		return
	}
	c.json(w, map[string]any{
		"id":          content.ID,
		"title":       content.Title,
		"description": content.Description,
		"html":        content.HTML,
		"status":      content.Status,
		"created":     content.CreatedAt.Format("2006-01-02T15:04:05Z"),
	})
}

// UpdatePageCode updates the HTML code for a page.
func (c *AdminAPIController) UpdatePageCode(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	if user == nil {
		c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

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

	var req struct {
		HTML string `json:"html"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	// Create new content version with updated HTML
	content := &models.PageContent{
		PageID:      page.ID,
		Title:       page.Title(),
		Description: page.Description(),
		HTML:        req.HTML,
		CreatedBy:   user.ID,
		Status:      models.StatusDraft,
	}
	if _, err := models.PageContents.Insert(content); err != nil {
		c.jsonError(w, "Failed to save content", http.StatusInternalServerError)
		return
	}

	c.json(w, map[string]any{"ok": true})
}

// PublishPage toggles the publish status of a page.
func (c *AdminAPIController) PublishPage(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	if user == nil {
		c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

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

	// Toggle publish status
	newStatus := models.StatusPublished
	if page.IsPublished() {
		newStatus = models.StatusDraft
	}

	content := &models.PageContent{
		PageID:      page.ID,
		Title:       page.Title(),
		Description: page.Description(),
		HTML:        page.HTML(),
		CreatedBy:   user.ID,
		Status:      newStatus,
	}
	if _, err := models.PageContents.Insert(content); err != nil {
		c.jsonError(w, "Failed to update status", http.StatusInternalServerError)
		return
	}

	c.json(w, pageToJSON(page))
}

// RestorePageVersion restores a page to a previous version.
func (c *AdminAPIController) RestorePageVersion(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	if user == nil {
		c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	id := r.PathValue("id")
	contentId := r.PathValue("contentId")

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

	oldContent, err := models.PageContents.Get(contentId)
	if err != nil {
		c.jsonError(w, "Content version not found", http.StatusNotFound)
		return
	}

	// Create new version with old content
	content := &models.PageContent{
		PageID:      page.ID,
		Title:       oldContent.Title,
		Description: oldContent.Description,
		HTML:        oldContent.HTML,
		CreatedBy:   user.ID,
		Status:      models.StatusDraft,
	}
	if _, err := models.PageContents.Insert(content); err != nil {
		c.jsonError(w, "Failed to restore version", http.StatusInternalServerError)
		return
	}

	c.json(w, map[string]any{"ok": true})
}

// --- Collections API ---

// ListCollections returns all collections.
func (c *AdminAPIController) ListCollections(w http.ResponseWriter, r *http.Request) {
	collections, err := models.Collections.Search("")
	if err != nil {
		c.jsonError(w, "Failed to list collections", http.StatusInternalServerError)
		return
	}

	items := make([]map[string]any, len(collections))
	for i, col := range collections {
		items[i] = collectionToJSON(col)
	}

	c.json(w, items)
}

// GetCollection returns a single collection with its schema.
func (c *AdminAPIController) GetCollection(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	col, err := models.Collections.Get(id)
	if err != nil {
		c.jsonError(w, "Collection not found", http.StatusNotFound)
		return
	}
	c.json(w, collectionToJSON(col))
}

// CreateCollection creates a new collection.
func (c *AdminAPIController) CreateCollection(w http.ResponseWriter, r *http.Request) {
	var req struct {
		ID          string `json:"id"`
		Name        string `json:"name"`
		Description string `json:"description"`
		Schema      string `json:"schema"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	col := &models.Collection{
		Name:        req.Name,
		Description: req.Description,
		Schema:      req.Schema,
		Type:        models.CollectionTypeBase,
	}

	if req.ID != "" {
		col.ID = req.ID
	}

	id, err := models.Collections.Insert(col)
	if err != nil {
		c.jsonError(w, "Failed to create collection", http.StatusInternalServerError)
		return
	}

	col, _ = models.Collections.Get(id)
	c.json(w, collectionToJSON(col), http.StatusCreated)
}

// UpdateCollection updates a collection.
func (c *AdminAPIController) UpdateCollection(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	col, err := models.Collections.Get(id)
	if err != nil {
		c.jsonError(w, "Collection not found", http.StatusNotFound)
		return
	}

	if col.System {
		c.jsonError(w, "Cannot modify system collection", http.StatusForbidden)
		return
	}

	var req map[string]any
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	if v, ok := req["name"].(string); ok {
		col.Name = v
	}
	if v, ok := req["description"].(string); ok {
		col.Description = v
	}
	if v, ok := req["schema"].(string); ok {
		col.Schema = v
	}
	if v, ok := req["listRule"].(string); ok {
		col.ListRule = v
	}
	if v, ok := req["viewRule"].(string); ok {
		col.ViewRule = v
	}
	if v, ok := req["createRule"].(string); ok {
		col.CreateRule = v
	}
	if v, ok := req["updateRule"].(string); ok {
		col.UpdateRule = v
	}
	if v, ok := req["deleteRule"].(string); ok {
		col.DeleteRule = v
	}

	if err := models.Collections.Update(col); err != nil {
		c.jsonError(w, "Failed to update collection", http.StatusInternalServerError)
		return
	}

	c.json(w, collectionToJSON(col))
}

// DeleteCollection deletes a collection and all its documents.
func (c *AdminAPIController) DeleteCollection(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	col, err := models.Collections.Get(id)
	if err != nil {
		c.jsonError(w, "Collection not found", http.StatusNotFound)
		return
	}

	if col.System {
		c.jsonError(w, "Cannot delete system collection", http.StatusForbidden)
		return
	}

	// Delete all documents first
	docs, _ := col.Documents()
	for _, doc := range docs {
		models.Documents.Delete(doc)
	}

	if err := models.Collections.Delete(col); err != nil {
		c.jsonError(w, "Failed to delete collection", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

// --- Documents API ---

// ListDocuments returns all documents in a collection.
func (c *AdminAPIController) ListDocuments(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	col, err := models.Collections.Get(id)
	if err != nil {
		c.jsonError(w, "Collection not found", http.StatusNotFound)
		return
	}

	docs, err := col.Documents()
	if err != nil {
		c.jsonError(w, "Failed to list documents", http.StatusInternalServerError)
		return
	}

	items := make([]map[string]any, len(docs))
	for i, doc := range docs {
		items[i] = documentToJSON(doc)
	}

	c.json(w, map[string]any{
		"items":      items,
		"totalItems": len(items),
	})
}

// GetDocument returns a single document.
func (c *AdminAPIController) GetDocument(w http.ResponseWriter, r *http.Request) {
	docId := r.PathValue("docId")
	doc, err := models.Documents.Get(docId)
	if err != nil {
		c.jsonError(w, "Document not found", http.StatusNotFound)
		return
	}
	c.json(w, documentToJSON(doc))
}

// CreateDocument creates a new document in a collection.
func (c *AdminAPIController) CreateDocument(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	col, err := models.Collections.Get(id)
	if err != nil {
		c.jsonError(w, "Collection not found", http.StatusNotFound)
		return
	}

	var req struct {
		ID   string         `json:"id"`
		Data map[string]any `json:"data"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	dataBytes, _ := json.Marshal(req.Data)
	doc := &models.Document{
		CollectionID: col.ID,
		Data:         string(dataBytes),
	}

	if req.ID != "" {
		doc.ID = req.ID
	}

	docId, err := models.Documents.Insert(doc)
	if err != nil {
		c.jsonError(w, "Failed to create document", http.StatusInternalServerError)
		return
	}

	doc, _ = models.Documents.Get(docId)
	c.json(w, documentToJSON(doc), http.StatusCreated)
}

// UpdateDocument updates a document.
func (c *AdminAPIController) UpdateDocument(w http.ResponseWriter, r *http.Request) {
	docId := r.PathValue("docId")
	doc, err := models.Documents.Get(docId)
	if err != nil {
		c.jsonError(w, "Document not found", http.StatusNotFound)
		return
	}

	var req struct {
		Data map[string]any `json:"data"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	if err := doc.SetAll(req.Data); err != nil {
		c.jsonError(w, "Failed to set data", http.StatusBadRequest)
		return
	}

	if err := models.Documents.Update(doc); err != nil {
		c.jsonError(w, "Failed to update document", http.StatusInternalServerError)
		return
	}

	c.json(w, documentToJSON(doc))
}

// DeleteDocument deletes a document.
func (c *AdminAPIController) DeleteDocument(w http.ResponseWriter, r *http.Request) {
	docId := r.PathValue("docId")
	doc, err := models.Documents.Get(docId)
	if err != nil {
		c.jsonError(w, "Document not found", http.StatusNotFound)
		return
	}

	if err := models.Documents.Delete(doc); err != nil {
		c.jsonError(w, "Failed to delete document", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

// --- Partials API ---

// ListPartials returns all partials.
func (c *AdminAPIController) ListPartials(w http.ResponseWriter, r *http.Request) {
	partials, err := models.Partials.Search("ORDER BY Name")
	if err != nil {
		c.jsonError(w, "Failed to list partials", http.StatusInternalServerError)
		return
	}

	items := make([]map[string]any, len(partials))
	for i, p := range partials {
		items[i] = partialToJSON(p)
	}

	c.json(w, map[string]any{
		"items":      items,
		"totalItems": len(items),
	})
}

// GetPartial returns a single partial.
func (c *AdminAPIController) GetPartial(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	partial, err := models.Partials.Get(id)
	if err != nil {
		c.jsonError(w, "Partial not found", http.StatusNotFound)
		return
	}
	c.json(w, partialToJSON(partial))
}

// CreatePartial creates a new partial.
func (c *AdminAPIController) CreatePartial(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Slug        string `json:"slug"`
		Name        string `json:"name"`
		Description string `json:"description"`
		HTML        string `json:"html"`
		Published   bool   `json:"published"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	partial := &models.Partial{
		Name:        req.Name,
		Description: req.Description,
		HTML:        req.HTML,
		Published:   req.Published,
	}

	if req.Slug != "" {
		partial.ID = req.Slug
	}

	id, err := models.Partials.Insert(partial)
	if err != nil {
		c.jsonError(w, "Failed to create partial", http.StatusInternalServerError)
		return
	}

	partial, _ = models.Partials.Get(id)
	c.json(w, partialToJSON(partial), http.StatusCreated)
}

// UpdatePartial updates a partial.
func (c *AdminAPIController) UpdatePartial(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	partial, err := models.Partials.Get(id)
	if err != nil {
		c.jsonError(w, "Partial not found", http.StatusNotFound)
		return
	}

	var req map[string]any
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	if v, ok := req["name"].(string); ok {
		partial.Name = v
	}
	if v, ok := req["description"].(string); ok {
		partial.Description = v
	}
	if v, ok := req["html"].(string); ok {
		partial.HTML = v
	}
	if v, ok := req["published"].(bool); ok {
		partial.Published = v
	}

	if err := models.Partials.Update(partial); err != nil {
		c.jsonError(w, "Failed to update partial", http.StatusInternalServerError)
		return
	}

	c.json(w, partialToJSON(partial))
}

// DeletePartial deletes a partial.
func (c *AdminAPIController) DeletePartial(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	partial, err := models.Partials.Get(id)
	if err != nil {
		c.jsonError(w, "Partial not found", http.StatusNotFound)
		return
	}

	if err := models.Partials.Delete(partial); err != nil {
		c.jsonError(w, "Failed to delete partial", http.StatusInternalServerError)
		return
	}

	w.WriteHeader(http.StatusNoContent)
}

// --- Settings API ---

// GetSettings returns site settings.
func (c *AdminAPIController) GetSettings(w http.ResponseWriter, r *http.Request) {
	c.json(w, map[string]any{
		"siteName":        helpers.GetSetting(models.SettingSiteName),
		"siteDescription": helpers.GetSetting(models.SettingSiteDescription),
		"aiProvider":      helpers.GetSetting(models.SettingAIProvider),
		"aiModel":         helpers.GetSetting(models.SettingAIModel),
		"signupEnabled":   helpers.GetSetting(models.SettingSignupEnabled) == "true",
		"hasApiKey":       helpers.GetSetting(models.SettingAIAPIKey) != "",
	})
}

// UpdateSettings updates site settings.
func (c *AdminAPIController) UpdateSettings(w http.ResponseWriter, r *http.Request) {
	var req map[string]any
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	if v, ok := req["siteName"].(string); ok {
		helpers.SetSetting(models.SettingSiteName, v)
	}
	if v, ok := req["siteDescription"].(string); ok {
		helpers.SetSetting(models.SettingSiteDescription, v)
	}
	if v, ok := req["aiProvider"].(string); ok {
		helpers.SetSetting(models.SettingAIProvider, v)
	}
	if v, ok := req["aiModel"].(string); ok {
		helpers.SetSetting(models.SettingAIModel, v)
	}
	if v, ok := req["aiApiKey"].(string); ok && v != "" {
		helpers.SetSetting(models.SettingAIAPIKey, v)
	}
	if v, ok := req["signupEnabled"].(bool); ok {
		val := "false"
		if v {
			val = "true"
		}
		helpers.SetSetting(models.SettingSignupEnabled, val)
	}

	c.json(w, map[string]any{"ok": true})
}

// DownloadBackup exports all site data as a JSON file.
// GET /admin/backup
func (c *AdminAPIController) DownloadBackup(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	if user == nil || user.Role != "admin" {
		http.Error(w, "Admin access required", http.StatusForbidden)
		return
	}

	// Build backup structure
	backup := map[string]any{
		"version":   "1.0",
		"exported":  time.Now().Format(time.RFC3339),
		"exportedBy": user.Email,
	}

	// Settings
	backup["settings"] = map[string]any{
		"siteName":        helpers.GetSetting(models.SettingSiteName),
		"siteDescription": helpers.GetSetting(models.SettingSiteDescription),
		"aiProvider":      helpers.GetSetting(models.SettingAIProvider),
		"aiModel":         helpers.GetSetting(models.SettingAIModel),
	}

	// Pages with content
	pages, _ := models.Pages.All()
	pagesData := make([]map[string]any, 0, len(pages))
	for _, p := range pages {
		pageData := map[string]any{
			"id":        p.ID,
			"parentId":  p.ParentID,
			"position":  p.Position,
			"published": p.IsPublished(),
			"created":   p.CreatedAt,
			"updated":   p.UpdatedAt,
		}
		// Get latest content
		contents, _ := models.PageContents.Search("WHERE PageID = ? ORDER BY Version DESC LIMIT 1", p.ID)
		if len(contents) > 0 {
			pageData["html"] = contents[0].HTML
		}
		pagesData = append(pagesData, pageData)
	}
	backup["pages"] = pagesData

	// Partials
	partials, _ := models.Partials.All()
	partialsData := make([]map[string]any, 0, len(partials))
	for _, p := range partials {
		partialsData = append(partialsData, map[string]any{
			"id":          p.ID,
			"name":        p.Name,
			"description": p.Description,
			"html":        p.HTML,
			"published":   p.Published,
			"created":     p.CreatedAt,
			"updated":     p.UpdatedAt,
		})
	}
	backup["partials"] = partialsData

	// Collections and documents
	collections, _ := models.Collections.All()
	collectionsData := make([]map[string]any, 0, len(collections))
	for _, col := range collections {
		docs, _ := models.Documents.Search("WHERE CollectionID = ?", col.ID)
		docsData := make([]map[string]any, 0, len(docs))
		for _, d := range docs {
			docsData = append(docsData, map[string]any{
				"id":      d.ID,
				"data":    d.Data,
				"created": d.CreatedAt,
				"updated": d.UpdatedAt,
			})
		}
		collectionsData = append(collectionsData, map[string]any{
			"id":          col.ID,
			"name":        col.Name,
			"description": col.Description,
			"schema":      col.Schema,
			"system":      col.System,
			"created":     col.CreatedAt,
			"updated":     col.UpdatedAt,
			"documents":   docsData,
		})
	}
	backup["collections"] = collectionsData

	// Files metadata (not the actual file contents)
	files, _ := models.Files.All()
	filesData := make([]map[string]any, 0, len(files))
	for _, f := range files {
		filesData = append(filesData, map[string]any{
			"id":        f.ID,
			"name":      f.Name,
			"path":      f.Path,
			"mimeType":  f.MimeType,
			"size":      f.Size,
			"published": f.Published,
			"created":   f.CreatedAt,
		})
	}
	backup["files"] = filesData

	// Users (without passwords)
	users, _ := models.Users.All()
	usersData := make([]map[string]any, 0, len(users))
	for _, u := range users {
		usersData = append(usersData, map[string]any{
			"id":       u.ID,
			"email":    u.Email,
			"name":     u.Name,
			"role":     u.Role,
			"verified": u.Verified,
			"created":  u.CreatedAt,
		})
	}
	backup["users"] = usersData

	// Set headers for file download
	filename := fmt.Sprintf("site-backup-%s.json", time.Now().Format("2006-01-02"))
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))

	// Log the backup
	helpers.AuditRead(r, user.ID, "backup", "site", "Full site backup")

	// Encode and send
	encoder := json.NewEncoder(w)
	encoder.SetIndent("", "  ")
	encoder.Encode(backup)
}

// --- Admin Events SSE ---

// SubscribeEvents returns an SSE stream of admin events (page/collection changes).
func (c *AdminAPIController) SubscribeEvents(w http.ResponseWriter, r *http.Request) {
	stream := c.BaseController.Stream(w)

	// Subscribe to all events
	sub := content.CollectionEvents.SubscribeAll()
	defer content.CollectionEvents.Unsubscribe(sub)

	ctx := r.Context()
	heartbeat := time.NewTicker(30 * time.Second)
	defer heartbeat.Stop()

	for {
		select {
		case <-ctx.Done():
			return

		case <-heartbeat.C:
			stream.Send("heartbeat", "{}")

		case event, ok := <-sub.Events:
			if !ok {
				return
			}

			// Format event type: entity_action (e.g., "page_create", "collection_update")
			eventType := fmt.Sprintf("%s_%s", event.EntityType, event.Type)

			// Build event data
			data := map[string]any{}
			if event.PageID != "" {
				data["id"] = event.PageID
			}
			if event.CollectionID != "" {
				data["collectionId"] = event.CollectionID
			}
			if event.RecordID != "" {
				data["recordId"] = event.RecordID
			}
			if event.Record != nil {
				for k, v := range event.Record {
					data[k] = v
				}
			}

			jsonData, _ := json.Marshal(data)
			stream.Send(eventType, string(jsonData))
		}
	}
}

// --- Audit Logs API ---

// ListAuditLogs returns paginated audit logs.
func (c *AdminAPIController) ListAuditLogs(w http.ResponseWriter, r *http.Request) {
	page := 1
	perPage := 50
	if v := r.URL.Query().Get("page"); v != "" {
		if p, err := parseInt(v); err == nil && p > 0 {
			page = p
		}
	}
	if v := r.URL.Query().Get("perPage"); v != "" {
		if p, err := parseInt(v); err == nil && p > 0 && p <= 100 {
			perPage = p
		}
	}

	// Build filter
	var conditions []string
	var args []any

	if action := r.URL.Query().Get("action"); action != "" {
		conditions = append(conditions, "Action = ?")
		args = append(args, action)
	}
	if resourceType := r.URL.Query().Get("resourceType"); resourceType != "" {
		conditions = append(conditions, "ResourceType = ?")
		args = append(args, resourceType)
	}

	whereClause := ""
	if len(conditions) > 0 {
		whereClause = "WHERE " + conditions[0]
		for i := 1; i < len(conditions); i++ {
			whereClause += " AND " + conditions[i]
		}
	}

	totalItems := models.AuditLogs.Count(whereClause, args...)
	totalPages := (totalItems + perPage - 1) / perPage
	if totalPages < 1 {
		totalPages = 1
	}

	offset := (page - 1) * perPage
	query := whereClause + " ORDER BY CreatedAt DESC LIMIT ? OFFSET ?"
	args = append(args, perPage, offset)

	logs, err := models.AuditLogs.Search(query, args...)
	if err != nil {
		c.jsonError(w, "Failed to fetch audit logs", http.StatusInternalServerError)
		return
	}

	items := make([]map[string]any, len(logs))
	for i, log := range logs {
		var userName, userEmail string
		if user := log.User(); user != nil {
			userName = user.Name
			userEmail = user.Email
		}
		items[i] = map[string]any{
			"id":           log.ID,
			"userId":       log.UserID,
			"userName":     userName,
			"userEmail":    userEmail,
			"action":       log.Action,
			"resourceType": log.ResourceType,
			"resourceId":   log.ResourceID,
			"resourceName": log.ResourceName,
			"ip":           log.IP,
			"created":      log.CreatedAt.Format("2006-01-02T15:04:05Z"),
		}
	}

	c.json(w, map[string]any{
		"items":      items,
		"page":       page,
		"perPage":    perPage,
		"totalItems": totalItems,
		"totalPages": totalPages,
	})
}

// parseInt is a helper to parse integer strings.
func parseInt(s string) (int, error) {
	var i int
	_, err := fmt.Sscanf(s, "%d", &i)
	return i, err
}

// --- Conversations API ---

// ListConversations returns all conversations for the current user.
func (c *AdminAPIController) ListConversations(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	if user == nil {
		c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	conversations := assist.GetUserConversations(user.ID)
	items := make([]map[string]any, len(conversations))
	for i, conv := range conversations {
		items[i] = conversationToJSON(conv)
	}

	c.json(w, map[string]any{
		"items":      items,
		"totalItems": len(items),
	})
}

// GetConversation returns a single conversation with its messages.
func (c *AdminAPIController) GetConversation(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	conv, err := models.Conversations.Get(id)
	if err != nil {
		c.jsonError(w, "Conversation not found", http.StatusNotFound)
		return
	}

	messages := assist.GetMessages(conv)
	msgItems := make([]map[string]any, len(messages))
	for i, msg := range messages {
		msgItems[i] = messageToJSON(msg)
	}

	result := conversationToJSON(conv)
	result["messages"] = msgItems
	result["canUndo"] = assist.ConvCanUndo(conv)
	result["canRedo"] = assist.ConvCanRedo(conv)
	result["streamingMessageId"] = assist.StreamingMessageID(conv)

	c.json(w, result)
}

// CreateConversation creates a new conversation with an optional initial message.
func (c *AdminAPIController) CreateConversation(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	if user == nil {
		c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	var req struct {
		Content string   `json:"content"`
		FileIds []string `json:"fileIds"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	// Create conversation
	title := "New Conversation"
	if req.Content != "" {
		title = helpers.TruncateTitle(req.Content, 50)
	}

	conv := &models.Conversation{
		UserID: user.ID,
		Title:  title,
	}

	convID, err := models.Conversations.Insert(conv)
	if err != nil {
		c.jsonError(w, "Failed to create conversation", http.StatusInternalServerError)
		return
	}

	// If content provided, create user message and pending assistant message
	if req.Content != "" {
		userMsg := &models.Message{
			ConversationID: convID,
			Role:           assist.RoleUser,
			Content:        req.Content,
			Status:         "complete",
		}
		msgID, err := models.Messages.Insert(userMsg)
		if err != nil {
			c.jsonError(w, "Failed to save message", http.StatusInternalServerError)
			return
		}

		// Re-fetch to get the full message with ID, then attach files
		userMsg, _ = models.Messages.Get(msgID)
		for _, fileID := range req.FileIds {
			if err := userMsg.AttachFile(fileID); err != nil {
				fmt.Printf("[CHAT] Failed to attach file %s to message %s: %v\n", fileID, userMsg.ID, err)
			}
		}

		assistantMsg := &models.Message{
			ConversationID: convID,
			Role:           assist.RoleAssistant,
			Content:        "",
			Status:         "pending",
		}
		if _, err := models.Messages.Insert(assistantMsg); err != nil {
			c.jsonError(w, "Failed to create response", http.StatusInternalServerError)
			return
		}
	}

	conv, _ = models.Conversations.Get(convID)
	c.json(w, conversationToJSON(conv), http.StatusCreated)
}

// UpdateConversation updates a conversation's settings (e.g., model).
func (c *AdminAPIController) UpdateConversation(w http.ResponseWriter, r *http.Request) {
	convID := r.PathValue("id")
	conv, err := models.Conversations.Get(convID)
	if err != nil {
		c.jsonError(w, "Conversation not found", http.StatusNotFound)
		return
	}

	var req struct {
		Model string `json:"model"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	// Update model if provided
	if req.Model != "" {
		if err := assist.SetConversationModel(conv, req.Model); err != nil {
			c.jsonError(w, "Failed to update model", http.StatusInternalServerError)
			return
		}
	}

	c.json(w, map[string]any{
		"ok":    true,
		"model": assist.GetConversationModel(conv),
	})
}

// SendMessage adds a user message to an existing conversation.
func (c *AdminAPIController) SendMessage(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	if user == nil {
		c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	// Rate limit
	key := r.RemoteAddr
	if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
		key = ip
	}
	if !access.ChatLimiter.Allow(key) {
		c.jsonError(w, "Too many messages. Please wait a moment.", http.StatusTooManyRequests)
		return
	}

	convID := r.PathValue("id")
	conv, err := models.Conversations.Get(convID)
	if err != nil {
		c.jsonError(w, "Conversation not found", http.StatusNotFound)
		return
	}

	var req struct {
		Content string   `json:"content"`
		FileIds []string `json:"fileIds"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		c.jsonError(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	if req.Content == "" {
		c.jsonError(w, "Message content required", http.StatusBadRequest)
		return
	}

	// Save user message
	userMsg := &models.Message{
		ConversationID: convID,
		Role:           assist.RoleUser,
		Content:        req.Content,
		Status:         "complete",
	}
	msgID, err := models.Messages.Insert(userMsg)
	if err != nil {
		c.jsonError(w, "Failed to save message", http.StatusInternalServerError)
		return
	}

	// Re-fetch to get the full message with ID
	userMsg, _ = models.Messages.Get(msgID)

	// Attach files to the message
	for _, fileID := range req.FileIds {
		if err := userMsg.AttachFile(fileID); err != nil {
			// Log error but continue - partial attachment is better than none
			fmt.Printf("[CHAT] Failed to attach file %s to message %s: %v\n", fileID, userMsg.ID, err)
		}
	}

	// Create pending assistant message
	assistantMsg := &models.Message{
		ConversationID: convID,
		Role:           assist.RoleAssistant,
		Content:        "",
		Status:         "pending",
	}
	assistantMsgID, err := models.Messages.Insert(assistantMsg)
	if err != nil {
		c.jsonError(w, "Failed to create response", http.StatusInternalServerError)
		return
	}

	// Update conversation title if needed
	if conv.Title == "New Conversation" {
		conv.Title = helpers.TruncateTitle(req.Content, 50)
	}
	models.Conversations.Update(conv)

	c.json(w, map[string]any{
		"userMessage": messageToJSON(userMsg),
		"assistantMessage": map[string]any{
			"id":             assistantMsgID,
			"conversationId": convID,
			"role":           assist.RoleAssistant,
			"content":        "",
			"status":         "pending",
		},
	})
}

// StreamConversation handles SSE streaming of AI response.
// This proxies to the existing chat controller's Stream method.
func (c *AdminAPIController) StreamConversation(w http.ResponseWriter, r *http.Request) {
	// The streaming logic is complex and already implemented in chat.go
	// We'll delegate to the chat controller (properly initialized without calling Setup)
	chatCtrl := &ChatController{}
	chatCtrl.App = c.App
	chatCtrl.Request = r
	chatCtrl.Stream(w, r)
}

// DeleteConversation deletes a conversation and all its messages.
func (c *AdminAPIController) DeleteConversation(w http.ResponseWriter, r *http.Request) {
	convID := r.PathValue("id")
	conv, err := models.Conversations.Get(convID)
	if err != nil {
		c.jsonError(w, "Conversation not found", http.StatusNotFound)
		return
	}

	// Delete all messages
	messages, _ := conv.Messages()
	for _, msg := range messages {
		// Delete mutations for each message
		mutations, _ := msg.Mutations()
		for _, m := range mutations {
			models.Mutations.Delete(m)
		}
		models.Messages.Delete(msg)
	}

	models.Conversations.Delete(conv)
	w.WriteHeader(http.StatusNoContent)
}

// UndoConversation undoes the last AI action.
func (c *AdminAPIController) UndoConversation(w http.ResponseWriter, r *http.Request) {
	convID := r.PathValue("id")
	conv, err := models.Conversations.Get(convID)
	if err != nil {
		c.jsonError(w, "Conversation not found", http.StatusNotFound)
		return
	}

	assist.UndoConversation(conv)
	c.json(w, map[string]any{
		"ok":      true,
		"canUndo": assist.ConvCanUndo(conv),
		"canRedo": assist.ConvCanRedo(conv),
	})
}

// RedoConversation redoes an undone action.
func (c *AdminAPIController) RedoConversation(w http.ResponseWriter, r *http.Request) {
	convID := r.PathValue("id")
	conv, err := models.Conversations.Get(convID)
	if err != nil {
		c.jsonError(w, "Conversation not found", http.StatusNotFound)
		return
	}

	assist.RedoConversation(conv)
	c.json(w, map[string]any{
		"ok":      true,
		"canUndo": assist.ConvCanUndo(conv),
		"canRedo": assist.ConvCanRedo(conv),
	})
}

// --- Helpers ---

func conversationToJSON(conv *models.Conversation) map[string]any {
	return map[string]any{
		"id":      conv.ID,
		"userId":  conv.UserID,
		"title":   conv.Title,
		"model":   assist.GetConversationModel(conv),
		"created": conv.CreatedAt.Format("2006-01-02T15:04:05Z"),
		"updated": conv.UpdatedAt.Format("2006-01-02T15:04:05Z"),
	}
}

func messageToJSON(msg *models.Message) map[string]any {
	result := map[string]any{
		"id":             msg.ID,
		"conversationId": msg.ConversationID,
		"role":           msg.Role,
		"content":        msg.Content,
		"status":         msg.Status,
		"created":        msg.CreatedAt.Format("2006-01-02T15:04:05Z"),
	}

	// Include tool calls if present
	if msg.ToolCalls != "" {
		var toolCalls []any
		if err := json.Unmarshal([]byte(msg.ToolCalls), &toolCalls); err == nil {
			result["toolCalls"] = toolCalls
		}
	}

	// Include attached files
	if files, err := msg.Files(); err == nil && len(files) > 0 {
		fileList := make([]map[string]any, len(files))
		for i, f := range files {
			fileList[i] = map[string]any{
				"id":   f.ID,
				"name": f.Name,
				"mime": f.MimeType,
				"size": f.Size,
			}
		}
		result["files"] = fileList
	}

	return result
}

func pageToJSON(p *models.Page) map[string]any {
	return map[string]any{
		"id":          p.ID,
		"title":       p.Title(),
		"html":        p.HTML(),
		"description": p.Description(),
		"parentId":    p.ParentID,
		"position":    p.Position,
		"published":   p.IsPublished(),
		"path":        p.Path(),
		"created":     p.CreatedAt.Format("2006-01-02T15:04:05Z"),
		"updated":     p.UpdatedAt.Format("2006-01-02T15:04:05Z"),
	}
}

func collectionToJSON(col *models.Collection) map[string]any {
	return map[string]any{
		"id":            col.ID,
		"name":          col.Name,
		"description":   col.Description,
		"type":          col.Type,
		"schema":        col.Schema,
		"listRule":      col.ListRule,
		"viewRule":      col.ViewRule,
		"createRule":    col.CreateRule,
		"updateRule":    col.UpdateRule,
		"deleteRule":    col.DeleteRule,
		"system":        col.System,
		"documentCount": col.DocumentCount(),
		"created":       col.CreatedAt.Format("2006-01-02T15:04:05Z"),
		"updated":       col.UpdatedAt.Format("2006-01-02T15:04:05Z"),
	}
}

func documentToJSON(doc *models.Document) map[string]any {
	var data map[string]any
	if doc.Data != "" {
		json.Unmarshal([]byte(doc.Data), &data)
	}
	return map[string]any{
		"id":           doc.ID,
		"collectionId": doc.CollectionID,
		"data":         data,
		"created":      doc.CreatedAt.Format("2006-01-02T15:04:05Z"),
		"updated":      doc.UpdatedAt.Format("2006-01-02T15:04:05Z"),
	}
}

func partialToJSON(p *models.Partial) map[string]any {
	return map[string]any{
		"id":          p.ID,
		"name":        p.Name,
		"description": p.Description,
		"html":        p.HTML,
		"published":   p.Published,
		"created":     p.CreatedAt.Format("2006-01-02T15:04:05Z"),
		"updated":     p.UpdatedAt.Format("2006-01-02T15:04:05Z"),
	}
}

// --- Tour API ---

// CompleteTour marks the onboarding tour as completed for the current user.
func (c *AdminAPIController) CompleteTour(w http.ResponseWriter, r *http.Request) {
	user := access.GetUserFromJWT(r)
	if user == nil {
		c.jsonError(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	if err := helpers.SetSetting(models.SettingTourCompleted+":"+user.ID, "true"); err != nil {
		c.jsonError(w, "Failed to save tour status", http.StatusInternalServerError)
		return
	}
	c.json(w, map[string]any{"ok": true})
}

func (c *AdminAPIController) json(w http.ResponseWriter, data any, status ...int) {
	w.Header().Set("Content-Type", "application/json")
	if len(status) > 0 {
		w.WriteHeader(status[0])
	}
	json.NewEncoder(w).Encode(data)
}

func (c *AdminAPIController) jsonError(w http.ResponseWriter, msg string, status int) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(map[string]any{"error": msg})
}
← Back