48.3 KB
api.go
package controllers

import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"

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

// API returns the API controller for public REST endpoints.
func API() (string, *APIController) {
	return "api", &APIController{}
}

// APIController handles public REST API endpoints for collections and documents.
type APIController struct {
	application.BaseController
}

// Setup registers routes.
// NOTE: These routes must be registered BEFORE the Site catch-all route.
func (c *APIController) Setup(app *application.App) {
	c.BaseController.Setup(app)

	// Public collection records API
	http.Handle("GET /api/collections/{collectionId}/records", app.Method(c, "ListRecords", nil))
	http.Handle("GET /api/collections/{collectionId}/records/{recordId}", app.Method(c, "ViewRecord", nil))
	http.Handle("POST /api/collections/{collectionId}/records", app.Method(c, "CreateRecord", nil))
	http.Handle("PATCH /api/collections/{collectionId}/records/{recordId}", app.Method(c, "UpdateRecord", nil))
	http.Handle("DELETE /api/collections/{collectionId}/records/{recordId}", app.Method(c, "DeleteRecord", nil))

	// Alias: /documents -> /records (for compatibility)
	http.Handle("GET /api/collections/{collectionId}/documents", app.Method(c, "ListRecords", nil))
	http.Handle("GET /api/collections/{collectionId}/documents/{recordId}", app.Method(c, "ViewRecord", nil))
	http.Handle("POST /api/collections/{collectionId}/documents", app.Method(c, "CreateRecord", nil))
	http.Handle("PATCH /api/collections/{collectionId}/documents/{recordId}", app.Method(c, "UpdateRecord", nil))
	http.Handle("DELETE /api/collections/{collectionId}/documents/{recordId}", app.Method(c, "DeleteRecord", nil))

	// Real-time subscription endpoint
	http.Handle("GET /api/collections/{collectionId}/subscribe", app.Method(c, "Subscribe", nil))

	// User API endpoints
	http.Handle("GET /api/users", app.Method(c, "ListUsers", nil))
	http.Handle("POST /api/users", app.Method(c, "CreateUser", nil))
	http.Handle("GET /api/users/{id}", app.Method(c, "ViewUser", nil))
	http.Handle("PATCH /api/users/{id}", app.Method(c, "UpdateUser", nil))
	http.Handle("DELETE /api/users/{id}", app.Method(c, "DeleteUser", nil))

	// File API endpoints (for CI/CD and programmatic access)
	http.Handle("GET /api/files", app.Method(c, "ListFiles", nil))
	http.Handle("POST /api/files", app.Method(c, "UploadFile", nil))
	http.Handle("GET /api/files/{id}", app.Method(c, "DownloadFile", nil))
	http.Handle("GET /api/files/{id}/metadata", app.Method(c, "FileMetadata", nil))
	http.Handle("PATCH /api/files/{id}", app.Method(c, "UpdateFile", nil))
	http.Handle("DELETE /api/files/{id}", app.Method(c, "DeleteFile", nil))

	// Search endpoint
	http.Handle("GET /api/search", app.Method(c, "Search", nil))

	// API documentation endpoints
	http.Handle("GET /api/docs", app.Method(c, "DocsUI", nil))
	http.Handle("GET /api/docs/openapi.json", app.Method(c, "OpenAPISpec", nil))

	// CORS preflight
	http.Handle("OPTIONS /api/collections/{collectionId}/records", app.Method(c, "CORSPreflight", nil))
	http.Handle("OPTIONS /api/collections/{collectionId}/records/{recordId}", app.Method(c, "CORSPreflight", nil))
	http.Handle("OPTIONS /api/collections/{collectionId}/documents", app.Method(c, "CORSPreflight", nil))
	http.Handle("OPTIONS /api/collections/{collectionId}/documents/{recordId}", app.Method(c, "CORSPreflight", nil))
	http.Handle("OPTIONS /api/files", app.Method(c, "CORSPreflight", nil))
	http.Handle("OPTIONS /api/files/{id}", app.Method(c, "CORSPreflight", nil))
	http.Handle("OPTIONS /api/users", app.Method(c, "CORSPreflight", nil))
	http.Handle("OPTIONS /api/users/{id}", app.Method(c, "CORSPreflight", nil))
	http.Handle("OPTIONS /api/search", app.Method(c, "CORSPreflight", nil))
}

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

// ListRecordsResponse is the response format for listing records.
type ListRecordsResponse struct {
	Page       int              `json:"page"`
	PerPage    int              `json:"perPage"`
	TotalItems int              `json:"totalItems,omitempty"`
	TotalPages int              `json:"totalPages,omitempty"`
	Items      []map[string]any `json:"items"`
}

// ListRecords returns paginated records from a collection.
// GET /api/collections/{collectionId}/records
func (c *APIController) ListRecords(w http.ResponseWriter, r *http.Request) {
	collectionID := r.PathValue("collectionId")

	collection, err := models.Collections.Get(collectionID)
	if err != nil {
		c.jsonError(w, "Collection not found", http.StatusNotFound)
		return
	}

	user := access.GetUserFromJWT(r)

	if !content.CheckCollectionRateLimit(w, r, collection, user) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	if collection.IsView() {
		c.listViewRecords(w, r, collection)
		return
	}

	auth := content.NewAuthorizer(r, collection, nil)
	authResult := auth.CanList()

	if !authResult.Allowed {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, authResult.Message, authResult.Status)
		return
	}

	allowedFields := c.getSchemaFieldNames(collection)

	filterExpr := r.URL.Query().Get("filter")
	sortExpr := r.URL.Query().Get("sort")
	fieldsExpr := r.URL.Query().Get("fields")
	expandExpr := r.URL.Query().Get("expand")
	skipTotal := r.URL.Query().Get("skipTotal") == "true"

	page := c.intParam(r, "page", 1)
	perPage := c.intParam(r, "perPage", 20)

	if page < 1 {
		page = 1
	}
	if perPage < 1 {
		perPage = 20
	}
	if perPage > 100 {
		perPage = 100
	}

	offset := (page - 1) * perPage

	var whereClause strings.Builder
	var params []any

	whereClause.WriteString("WHERE CollectionID = ?")
	params = append(params, collectionID)

	// Apply rule-based filter
	if authResult.SQLFilter != "" {
		whereClause.WriteString(" AND (")
		whereClause.WriteString(authResult.SQLFilter)
		whereClause.WriteString(")")
		params = append(params, authResult.Params...)
	}

	if filterExpr != "" {
		filterResult, err := content.ParseAPIFilter(filterExpr, allowedFields)
		if err != nil {
			c.jsonError(w, "Invalid filter: "+err.Error(), http.StatusBadRequest)
			return
		}
		if filterResult.Where != "" {
			whereClause.WriteString(" AND (")
			whereClause.WriteString(filterResult.Where)
			whereClause.WriteString(")")
			params = append(params, filterResult.Params...)
		}
	}

	var totalItems, totalPages int
	if !skipTotal {
		totalItems = models.Documents.Count(whereClause.String(), params...)
		totalPages = (totalItems + perPage - 1) / perPage
		if totalPages < 1 {
			totalPages = 1
		}
	}

	orderBy := "CreatedAt DESC"
	if sortExpr != "" {
		parsedSort, err := content.ParseSort(sortExpr, allowedFields)
		if err != nil {
			c.jsonError(w, "Invalid sort: "+err.Error(), http.StatusBadRequest)
			return
		}
		if parsedSort != "" {
			orderBy = parsedSort
		}
	}

	query := whereClause.String() + " ORDER BY " + orderBy + " LIMIT ? OFFSET ?"
	params = append(params, perPage, offset)

	docs, err := models.Documents.Search(query, params...)
	if err != nil {
		c.jsonError(w, "Failed to fetch records", http.StatusInternalServerError)
		return
	}

	selectedFields := content.ParseFields(fieldsExpr)
	expandPaths := content.ParseExpand(expandExpr)

	items := make([]map[string]any, 0, len(docs))
	expander := content.NewRelationExpander()

	for _, doc := range docs {
		item := content.DocumentToRecord(doc, collection)

		if len(expandPaths) > 0 {
			expander.ExpandRecord(item, collection, expandPaths, 1)
		}

		if len(selectedFields) > 0 {
			item = content.FilterFields(item, selectedFields)
		}
		items = append(items, item)
	}

	userID := ""
	if user != nil {
		userID = user.ID
	}
	helpers.AuditRead(r, userID, helpers.ResourceCollection, collectionID, collection.Name)

	content.SetCORSHeaders(w, r)

	response := ListRecordsResponse{
		Page:    page,
		PerPage: perPage,
		Items:   items,
	}

	if !skipTotal {
		response.TotalItems = totalItems
		response.TotalPages = totalPages
	}

	c.jsonResponse(w, response, http.StatusOK)
}

// getSchemaFieldNames returns the field names from a collection's schema.
func (c *APIController) getSchemaFieldNames(collection *models.Collection) []string {
	fields, err := content.GetFields(collection)
	if err != nil {
		return nil
	}

	names := make([]string, len(fields))
	for i, f := range fields {
		names[i] = f.Name
	}
	return names
}

// ViewRecord returns a single record by ID.
// GET /api/collections/{collectionId}/records/{recordId}
func (c *APIController) ViewRecord(w http.ResponseWriter, r *http.Request) {
	collectionID := r.PathValue("collectionId")
	recordID := r.PathValue("recordId")

	collection, err := models.Collections.Get(collectionID)
	if err != nil {
		c.jsonError(w, "Collection not found", http.StatusNotFound)
		return
	}

	user := access.GetUserFromJWT(r)

	if !content.CheckCollectionRateLimit(w, r, collection, user) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	if collection.IsView() {
		c.viewViewRecord(w, r, collection, recordID)
		return
	}

	doc, err := models.Documents.Get(recordID)
	if err != nil || doc.CollectionID != collectionID {
		c.jsonError(w, "Record not found", http.StatusNotFound)
		return
	}

	record := content.DocumentToRecord(doc, collection)

	auth := content.NewAuthorizer(r, collection, nil)
	authResult := auth.CanView(record)

	if !authResult.Allowed {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, authResult.Message, authResult.Status)
		return
	}

	expandExpr := r.URL.Query().Get("expand")
	fieldsExpr := r.URL.Query().Get("fields")

	userID := ""
	if user != nil {
		userID = user.ID
	}
	helpers.AuditRead(r, userID, helpers.ResourceDocument, recordID, collection.Name+" record")

	content.SetCORSHeaders(w, r)

	expandPaths := content.ParseExpand(expandExpr)
	if len(expandPaths) > 0 {
		content.ExpandRecordSimple(record, collection, expandPaths)
	}

	selectedFields := content.ParseFields(fieldsExpr)
	if len(selectedFields) > 0 {
		record = content.FilterFields(record, selectedFields)
	}

	c.jsonResponse(w, record, http.StatusOK)
}

// CreateRecord creates a new record in a collection.
// POST /api/collections/{collectionId}/records
func (c *APIController) CreateRecord(w http.ResponseWriter, r *http.Request) {
	collectionID := r.PathValue("collectionId")

	collection, err := models.Collections.Get(collectionID)
	if err != nil {
		c.jsonError(w, "Collection not found", http.StatusNotFound)
		return
	}

	user := access.GetUserFromJWT(r)

	if !content.CheckCollectionRateLimit(w, r, collection, user) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	if collection.IsView() {
		c.jsonError(w, "Cannot create records in view collections", http.StatusBadRequest)
		return
	}

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

	auth := content.NewAuthorizer(r, collection, data)
	authResult := auth.CanCreate(data)

	if !authResult.Allowed {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, authResult.Message, authResult.Status)
		return
	}

	if err := content.ValidateDocument(collection, data); err != nil {
		c.jsonError(w, "Validation failed: "+err.Error(), http.StatusBadRequest)
		return
	}

	if err := content.ProcessAutodate(collection, data, true); err != nil {
		c.jsonError(w, "Failed to process autodate fields: "+err.Error(), http.StatusInternalServerError)
		return
	}

	jsonData, err := json.Marshal(data)
	if err != nil {
		c.jsonError(w, "Failed to encode data", http.StatusInternalServerError)
		return
	}

	doc := &models.Document{
		CollectionID: collectionID,
		Data:         string(jsonData),
	}

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

	doc, err = models.Documents.Get(docID)
	if err != nil {
		doc = &models.Document{CollectionID: collectionID}
		doc.ID = docID
	}

	userID := ""
	if user != nil {
		userID = user.ID
	}
	helpers.AuditCreate(r, userID, helpers.ResourceDocument, docID, collection.Name+" record")

	content.SetCORSHeaders(w, r)

	record := content.DocumentToRecord(doc, collection)

	content.CollectionEvents.PublishCreate(collectionID, docID, record)

	c.jsonResponse(w, record, http.StatusCreated)
}

// UpdateRecord updates an existing record.
// PATCH /api/collections/{collectionId}/records/{recordId}
func (c *APIController) UpdateRecord(w http.ResponseWriter, r *http.Request) {
	collectionID := r.PathValue("collectionId")
	recordID := r.PathValue("recordId")

	collection, err := models.Collections.Get(collectionID)
	if err != nil {
		c.jsonError(w, "Collection not found", http.StatusNotFound)
		return
	}

	user := access.GetUserFromJWT(r)

	if !content.CheckCollectionRateLimit(w, r, collection, user) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	if collection.IsView() {
		c.jsonError(w, "Cannot update records in view collections", http.StatusBadRequest)
		return
	}

	doc, err := models.Documents.Get(recordID)
	if err != nil || doc.CollectionID != collectionID {
		c.jsonError(w, "Record not found", http.StatusNotFound)
		return
	}

	existingData := content.ParseDocumentData(doc)
	currentRecord := content.DocumentToRecord(doc, collection)

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

	auth := content.NewAuthorizer(r, collection, updates)
	authResult := auth.CanUpdate(currentRecord)

	if !authResult.Allowed {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, authResult.Message, authResult.Status)
		return
	}

	ifMatch := r.Header.Get("If-Match")
	if ifMatch != "" {
		expectedVersion := doc.UpdatedAt.Format("2006-01-02T15:04:05")
		if ifMatch != expectedVersion {
			c.jsonError(w, "Record was modified by another request. Please refresh and try again.", http.StatusConflict)
			return
		}
	}

	processedUpdates := content.ProcessFieldModifiers(updates, existingData)
	for k, v := range processedUpdates {
		existingData[k] = v
	}

	if err := content.ValidateDocument(collection, existingData); err != nil {
		c.jsonError(w, "Validation failed: "+err.Error(), http.StatusBadRequest)
		return
	}

	if err := content.ProcessAutodate(collection, existingData, false); err != nil {
		c.jsonError(w, "Failed to process autodate fields: "+err.Error(), http.StatusInternalServerError)
		return
	}

	jsonData, err := json.Marshal(existingData)
	if err != nil {
		c.jsonError(w, "Failed to encode data", http.StatusInternalServerError)
		return
	}
	doc.Data = string(jsonData)

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

	if refetched, err := models.Documents.Get(recordID); err == nil {
		doc = refetched
	}

	userID := ""
	if user != nil {
		userID = user.ID
	}
	helpers.AuditUpdate(r, userID, helpers.ResourceDocument, recordID, collection.Name+" record", nil)

	content.SetCORSHeaders(w, r)

	record := content.DocumentToRecord(doc, collection)

	content.CollectionEvents.PublishUpdate(collectionID, recordID, record)

	c.jsonResponse(w, record, http.StatusOK)
}

// DeleteRecord deletes a record.
// DELETE /api/collections/{collectionId}/records/{recordId}
func (c *APIController) DeleteRecord(w http.ResponseWriter, r *http.Request) {
	collectionID := r.PathValue("collectionId")
	recordID := r.PathValue("recordId")

	collection, err := models.Collections.Get(collectionID)
	if err != nil {
		c.jsonError(w, "Collection not found", http.StatusNotFound)
		return
	}

	user := access.GetUserFromJWT(r)

	if !content.CheckCollectionRateLimit(w, r, collection, user) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	if collection.IsView() {
		c.jsonError(w, "Cannot delete records from view collections", http.StatusBadRequest)
		return
	}

	doc, err := models.Documents.Get(recordID)
	if err != nil || doc.CollectionID != collectionID {
		c.jsonError(w, "Record not found", http.StatusNotFound)
		return
	}

	record := content.DocumentToRecord(doc, collection)

	auth := content.NewAuthorizer(r, collection, nil)
	authResult := auth.CanDelete(record)

	if !authResult.Allowed {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, authResult.Message, authResult.Status)
		return
	}

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

	content.CollectionEvents.PublishDelete(collectionID, recordID)

	userID := ""
	if user != nil {
		userID = user.ID
	}
	helpers.AuditDelete(r, userID, helpers.ResourceDocument, recordID, collection.Name+" record")

	content.SetCORSHeaders(w, r)

	w.WriteHeader(http.StatusNoContent)
}

// CORSPreflight handles OPTIONS requests for CORS preflight.
func (c *APIController) CORSPreflight(w http.ResponseWriter, r *http.Request) {
	content.SetCORSHeaders(w, r)
	w.WriteHeader(http.StatusNoContent)
}

// Search performs full-text search across all content.
// GET /api/search?q=query&type=page&collection=blog&page=1&perPage=20
func (c *APIController) Search(w http.ResponseWriter, r *http.Request) {
	content.SetCORSHeaders(w, r)

	q := r.URL.Query().Get("q")
	if q == "" {
		c.jsonError(w, "query parameter 'q' is required", http.StatusBadRequest)
		return
	}

	page := c.intParam(r, "page", 1)
	perPage := c.intParam(r, "perPage", 20)
	entityType := r.URL.Query().Get("type")
	collectionID := r.URL.Query().Get("collection")

	results, total, err := search.Search(search.SearchOptions{
		Query:        q,
		EntityType:   entityType,
		CollectionID: collectionID,
		Page:         page,
		PerPage:      perPage,
	})
	if err != nil {
		c.jsonError(w, "Search failed: "+err.Error(), http.StatusInternalServerError)
		return
	}

	totalPages := (total + perPage - 1) / perPage
	if totalPages < 1 {
		totalPages = 1
	}

	c.jsonResponse(w, map[string]any{
		"page":       page,
		"perPage":    perPage,
		"totalItems": total,
		"totalPages": totalPages,
		"items":      results,
	}, http.StatusOK)
}

// --- Helper methods ---

// jsonResponse writes a JSON response with the given status code.
func (c *APIController) jsonResponse(w http.ResponseWriter, data any, status int) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(data)
}

// jsonError writes a JSON error response.
func (c *APIController) jsonError(w http.ResponseWriter, message string, status int) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(map[string]any{
		"status":  status,
		"message": message,
	})
}

// intParam parses an integer query parameter with a default value.
func (c *APIController) intParam(r *http.Request, name string, defaultVal int) int {
	val := r.URL.Query().Get(name)
	if val == "" {
		return defaultVal
	}
	i, err := strconv.Atoi(val)
	if err != nil {
		return defaultVal
	}
	return i
}

// --- View collection handlers ---

// listViewRecords handles listing records from a view collection.
func (c *APIController) listViewRecords(w http.ResponseWriter, r *http.Request, collection *models.Collection) {
	user := access.GetUserFromJWT(r)
	ruleCtx := content.NewContext(r, user, nil)

	listRule := collection.ListRule
	if listRule != "" {
		ruleType := content.ParseRuleType(listRule)
		switch ruleType {
		case content.RuleTypeLocked:
			if user == nil || user.Role != "admin" {
				if user == nil {
					c.jsonError(w, "Authentication required", http.StatusUnauthorized)
				} else {
					c.jsonError(w, "Access denied", http.StatusForbidden)
				}
				return
			}
		case content.RuleTypePublic:
			// Anyone can access
		case content.RuleTypeExpression:
			allowed, err := content.Evaluate(listRule, ruleCtx, nil)
			if err != nil || !allowed {
				if user == nil {
					c.jsonError(w, "Authentication required", http.StatusUnauthorized)
				} else {
					c.jsonError(w, "Access denied", http.StatusForbidden)
				}
				return
			}
		}
	} else {
		if !access.CheckAccess(user, access.ResourceCollection, collection.ID, access.PermRead) {
			if user == nil {
				c.jsonError(w, "Authentication required", http.StatusUnauthorized)
			} else {
				c.jsonError(w, "Access denied", http.StatusForbidden)
			}
			return
		}
	}

	page := c.intParam(r, "page", 1)
	perPage := c.intParam(r, "perPage", 20)
	skipTotal := r.URL.Query().Get("skipTotal") == "true"

	if page < 1 {
		page = 1
	}
	if perPage < 1 {
		perPage = 20
	}
	if perPage > 100 {
		perPage = 100
	}

	offset := (page - 1) * perPage

	filterExpr := r.URL.Query().Get("filter")
	sortExpr := r.URL.Query().Get("sort")
	fieldsExpr := r.URL.Query().Get("fields")

	records, totalCount, err := content.ExecuteView(collection, filterExpr, sortExpr, perPage, offset)
	if err != nil {
		c.jsonError(w, "Failed to query view: "+err.Error(), http.StatusInternalServerError)
		return
	}

	selectedFields := content.ParseFields(fieldsExpr)

	items := make([]map[string]any, 0, len(records))
	for _, rec := range records {
		item := rec.ToJSON(collection.ID, collection.Name)

		if len(selectedFields) > 0 {
			item = content.FilterFields(item, selectedFields)
		}
		items = append(items, item)
	}

	var totalPages int
	if !skipTotal {
		totalPages = (totalCount + perPage - 1) / perPage
		if totalPages < 1 {
			totalPages = 1
		}
	}

	userID := ""
	if user != nil {
		userID = user.ID
	}
	helpers.AuditRead(r, userID, helpers.ResourceCollection, collection.ID, collection.Name+" (view)")

	content.SetCORSHeaders(w, r)

	response := ListRecordsResponse{
		Page:    page,
		PerPage: perPage,
		Items:   items,
	}

	if !skipTotal {
		response.TotalItems = totalCount
		response.TotalPages = totalPages
	}

	c.jsonResponse(w, response, http.StatusOK)
}

// viewViewRecord handles viewing a single record from a view collection.
func (c *APIController) viewViewRecord(w http.ResponseWriter, r *http.Request, collection *models.Collection, recordID string) {
	user := access.GetUserFromJWT(r)
	ruleCtx := content.NewContext(r, user, nil)

	viewRule := collection.ViewRule
	if viewRule != "" {
		ruleType := content.ParseRuleType(viewRule)
		switch ruleType {
		case content.RuleTypeLocked:
			if user == nil || user.Role != "admin" {
				c.jsonError(w, "Record not found", http.StatusNotFound)
				return
			}
		case content.RuleTypePublic:
			// Anyone can access
		case content.RuleTypeExpression:
			allowed, err := content.Evaluate(viewRule, ruleCtx, nil)
			if err != nil || !allowed {
				c.jsonError(w, "Record not found", http.StatusNotFound)
				return
			}
		}
	} else {
		if !access.CheckAccess(user, access.ResourceCollection, collection.ID, access.PermRead) {
			c.jsonError(w, "Record not found", http.StatusNotFound)
			return
		}
	}

	record, err := content.ExecuteViewSingle(collection, recordID)
	if err != nil {
		c.jsonError(w, "Record not found", http.StatusNotFound)
		return
	}

	fieldsExpr := r.URL.Query().Get("fields")
	selectedFields := content.ParseFields(fieldsExpr)

	item := record.ToJSON(collection.ID, collection.Name)

	if len(selectedFields) > 0 {
		item = content.FilterFields(item, selectedFields)
	}

	userID := ""
	if user != nil {
		userID = user.ID
	}
	helpers.AuditRead(r, userID, helpers.ResourceDocument, recordID, collection.Name+" view record")

	content.SetCORSHeaders(w, r)

	c.jsonResponse(w, item, http.StatusOK)
}

// Subscribe streams real-time events for a collection.
// GET /api/collections/{collectionId}/subscribe
func (c *APIController) Subscribe(w http.ResponseWriter, r *http.Request) {
	collectionID := r.PathValue("collectionId")

	collection, err := models.Collections.Get(collectionID)
	if err != nil {
		c.jsonError(w, "Collection not found", http.StatusNotFound)
		return
	}

	auth := content.NewAuthorizer(r, collection, nil)

	authResult := auth.CanList()
	if !authResult.Allowed {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, authResult.Message, authResult.Status)
		return
	}

	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	content.SetCORSHeaders(w, r)

	if f, ok := w.(http.Flusher); ok {
		f.Flush()
	}

	sub := content.CollectionEvents.Subscribe(collectionID)
	defer content.CollectionEvents.Unsubscribe(sub)

	connectData := map[string]any{
		"clientId": sub.ID,
	}
	connectJSON, _ := json.Marshal(connectData)
	fmt.Fprintf(w, "event: connect\ndata: %s\n\n", connectJSON)
	if f, ok := w.(http.Flusher); ok {
		f.Flush()
	}

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

	filterExpr := r.URL.Query().Get("filter")
	allowedFields := c.getSchemaFieldNames(collection)

	listRule := collection.ListRule

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

		case <-heartbeat.C:
			fmt.Fprintf(w, "event: heartbeat\ndata: {}\n\n")
			if f, ok := w.(http.Flusher); ok {
				f.Flush()
			}

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

			if listRule != "" && content.ParseRuleType(listRule) == content.RuleTypeExpression && event.Record != nil {
				allowed, err := content.Evaluate(listRule, auth.Context(), event.Record)
				if err != nil || !allowed {
					continue
				}
			}

			if filterExpr != "" && event.Record != nil {
				filterResult, err := content.ParseAPIFilter(filterExpr, allowedFields)
				if err == nil && filterResult.Where != "" {
					matches := content.MatchesAPIFilter(event.Record, filterResult)
					if !matches {
						continue
					}
				}
			}

			eventData := map[string]any{
				"action":   string(event.Type),
				"recordId": event.RecordID,
			}
			if event.Record != nil {
				eventData["record"] = event.Record
			}

			eventJSON, err := json.Marshal(eventData)
			if err != nil {
				continue
			}

			fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, eventJSON)
			if f, ok := w.(http.Flusher); ok {
				f.Flush()
			}
		}
	}
}

// --- File API Endpoints ---

// FileResponse is the response format for file metadata.
type FileResponse struct {
	ID        string `json:"id"`
	Name      string `json:"name"`
	MimeType  string `json:"mimeType"`
	Size      int64  `json:"size"`
	Path      string `json:"path,omitempty"`
	Published bool   `json:"published"`
	UserID    string `json:"userId,omitempty"`
	Created   string `json:"created"`
	Updated   string `json:"updated"`
}

// ListFilesResponse is the response format for listing files.
type ListFilesResponse struct {
	Page       int            `json:"page"`
	PerPage    int            `json:"perPage"`
	TotalItems int            `json:"totalItems"`
	TotalPages int            `json:"totalPages"`
	Items      []FileResponse `json:"items"`
}

// fileToResponse converts a File model to API response format.
func (c *APIController) fileToResponse(f *models.File) FileResponse {
	return FileResponse{
		ID:        f.ID,
		Name:      f.Name,
		MimeType:  f.MimeType,
		Size:      f.Size,
		Path:      f.Path,
		Published: f.Published,
		UserID:    f.UserID,
		Created:   f.CreatedAt.Format("2006-01-02T15:04:05Z"),
		Updated:   f.UpdatedAt.Format("2006-01-02T15:04:05Z"),
	}
}

// ListFiles returns paginated files.
// GET /api/files
func (c *APIController) ListFiles(w http.ResponseWriter, r *http.Request) {
	if !content.CheckRateLimit(r) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	user := access.GetUserFromJWT(r)
	if user == nil {
		c.jsonError(w, "Authentication required", http.StatusUnauthorized)
		return
	}

	page := c.intParam(r, "page", 1)
	perPage := c.intParam(r, "perPage", 20)

	if page < 1 {
		page = 1
	}
	if perPage < 1 {
		perPage = 20
	}
	if perPage > 100 {
		perPage = 100
	}

	offset := (page - 1) * perPage

	var whereClause string
	var params []any

	// Admins see all files, others see only their own
	if user.Role != "admin" {
		whereClause = "WHERE UserID = ?"
		params = append(params, user.ID)
	}

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

	query := whereClause + " ORDER BY CreatedAt DESC LIMIT ? OFFSET ?"
	params = append(params, perPage, offset)

	files, err := models.Files.Search(query, params...)
	if err != nil {
		c.jsonError(w, "Failed to fetch files", http.StatusInternalServerError)
		return
	}

	items := make([]FileResponse, len(files))
	for i, f := range files {
		items[i] = c.fileToResponse(f)
	}

	content.SetCORSHeaders(w, r)

	c.jsonResponse(w, ListFilesResponse{
		Page:       page,
		PerPage:    perPage,
		TotalItems: totalItems,
		TotalPages: totalPages,
		Items:      items,
	}, http.StatusOK)
}

// UploadFile uploads a new file.
// POST /api/files
func (c *APIController) UploadFile(w http.ResponseWriter, r *http.Request) {
	if !content.CheckRateLimit(r) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	user := access.GetUserFromJWT(r)
	if user == nil {
		c.jsonError(w, "Authentication required", http.StatusUnauthorized)
		return
	}

	// Limit upload size
	r.Body = http.MaxBytesReader(w, r.Body, content.MaxFileSize)

	var data []byte
	var filename string
	var mimeType string

	contentType := r.Header.Get("Content-Type")
	if strings.HasPrefix(contentType, "multipart/form-data") {
		// Multipart form upload
		if err := r.ParseMultipartForm(content.MaxFileSize); err != nil {
			c.jsonError(w, "File too large (max 10MB)", http.StatusBadRequest)
			return
		}

		file, header, err := r.FormFile("file")
		if err != nil {
			c.jsonError(w, "No file provided", http.StatusBadRequest)
			return
		}
		defer file.Close()

		filename = header.Filename
		data, err = io.ReadAll(file)
		if err != nil {
			c.jsonError(w, "Failed to read file", http.StatusInternalServerError)
			return
		}
	} else {
		// Raw binary upload
		filename = r.Header.Get("X-Filename")
		if filename == "" {
			filename = "upload"
		}

		var err error
		data, err = io.ReadAll(r.Body)
		if err != nil {
			c.jsonError(w, "Failed to read request body", http.StatusBadRequest)
			return
		}
	}

	// Validate upload
	if err := content.ValidateUpload(filename, int64(len(data)), data); err != nil {
		c.jsonError(w, err.Error(), http.StatusBadRequest)
		return
	}

	// Detect MIME type
	detectedMime := content.DetectMimeType(data)
	mimeType = detectedMime
	if mimeType == "application/octet-stream" {
		// Use header hint if available
		if headerMime := r.Header.Get("Content-Type"); headerMime != "" && headerMime != "application/octet-stream" && !strings.HasPrefix(headerMime, "multipart/") {
			mimeType = headerMime
		}
	}

	// Create file record
	f := &models.File{
		Name:     filename,
		MimeType: mimeType,
		Size:     int64(len(data)),
		Data:     data,
		UserID:   user.ID,
	}

	fileID, err := models.Files.Insert(f)
	if err != nil {
		c.jsonError(w, "Failed to save file", http.StatusInternalServerError)
		return
	}

	f, err = models.Files.Get(fileID)
	if err != nil {
		c.jsonError(w, "Failed to retrieve file", http.StatusInternalServerError)
		return
	}

	helpers.AuditCreate(r, user.ID, helpers.ResourceFile, fileID, filename)

	content.SetCORSHeaders(w, r)

	c.jsonResponse(w, c.fileToResponse(f), http.StatusCreated)
}

// DownloadFile downloads a file's content.
// GET /api/files/{id}
func (c *APIController) DownloadFile(w http.ResponseWriter, r *http.Request) {
	if !content.CheckRateLimit(r) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	fileID := r.PathValue("id")

	file, err := models.Files.Get(fileID)
	if err != nil {
		c.jsonError(w, "File not found", http.StatusNotFound)
		return
	}

	// Check access: published files are public, others require auth
	if !file.Published {
		user := access.GetUserFromJWT(r)
		if user == nil {
			c.jsonError(w, "Authentication required", http.StatusUnauthorized)
			return
		}
		// Only owner or admin can access unpublished files
		if user.ID != file.UserID && user.Role != "admin" {
			c.jsonError(w, "File not found", http.StatusNotFound)
			return
		}
	}

	content.SetCORSHeaders(w, r)

	w.Header().Set("Content-Type", file.MimeType)
	w.Header().Set("Content-Length", fmt.Sprintf("%d", file.Size))

	disposition := "inline"
	if r.URL.Query().Get("download") == "1" {
		disposition = "attachment"
	}
	sanitizedName := content.SanitizeFilename(file.Name)
	w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, sanitizedName))

	w.Header().Set("Cache-Control", "public, max-age=3600")

	w.Write(file.Data)
}

// FileMetadata returns file metadata without content.
// GET /api/files/{id}/metadata
func (c *APIController) FileMetadata(w http.ResponseWriter, r *http.Request) {
	if !content.CheckRateLimit(r) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	fileID := r.PathValue("id")

	file, err := models.Files.Get(fileID)
	if err != nil {
		c.jsonError(w, "File not found", http.StatusNotFound)
		return
	}

	// Check access
	if !file.Published {
		user := access.GetUserFromJWT(r)
		if user == nil {
			c.jsonError(w, "Authentication required", http.StatusUnauthorized)
			return
		}
		if user.ID != file.UserID && user.Role != "admin" {
			c.jsonError(w, "File not found", http.StatusNotFound)
			return
		}
	}

	content.SetCORSHeaders(w, r)

	c.jsonResponse(w, c.fileToResponse(file), http.StatusOK)
}

// UpdateFile updates a file's metadata (path, published).
// PATCH /api/files/{id}
func (c *APIController) UpdateFile(w http.ResponseWriter, r *http.Request) {
	if !content.CheckRateLimit(r) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	user := access.GetUserFromJWT(r)
	if user == nil {
		c.jsonError(w, "Authentication required", http.StatusUnauthorized)
		return
	}

	fileID := r.PathValue("id")

	file, err := models.Files.Get(fileID)
	if err != nil {
		c.jsonError(w, "File not found", http.StatusNotFound)
		return
	}

	// Only owner or admin can update
	if user.ID != file.UserID && user.Role != "admin" {
		c.jsonError(w, "File not found", http.StatusNotFound)
		return
	}

	var updates struct {
		Path      *string `json:"path"`
		Published *bool   `json:"published"`
	}
	if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
		c.jsonError(w, "Invalid JSON body", http.StatusBadRequest)
		return
	}

	if updates.Published != nil {
		file.Published = *updates.Published
	}

	if updates.Path != nil {
		newPath := strings.TrimSpace(*updates.Path)
		newPath = strings.TrimPrefix(newPath, "/")

		if err := content.ValidatePath(newPath); err != nil {
			c.jsonError(w, err.Error(), http.StatusBadRequest)
			return
		}

		// Check path uniqueness
		if newPath != "" && newPath != file.Path {
			existing, _ := models.Files.First("WHERE Path = ? AND ID != ?", newPath, file.ID)
			if existing != nil {
				c.jsonError(w, "Path is already in use by another file", http.StatusConflict)
				return
			}
		}

		file.Path = newPath
	}

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

	helpers.AuditUpdate(r, user.ID, helpers.ResourceFile, fileID, file.Name, map[string]any{
		"published": file.Published,
		"path":      file.Path,
	})

	content.SetCORSHeaders(w, r)

	c.jsonResponse(w, c.fileToResponse(file), http.StatusOK)
}

// DeleteFile deletes a file.
// DELETE /api/files/{id}
func (c *APIController) DeleteFile(w http.ResponseWriter, r *http.Request) {
	if !content.CheckRateLimit(r) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	user := access.GetUserFromJWT(r)
	if user == nil {
		c.jsonError(w, "Authentication required", http.StatusUnauthorized)
		return
	}

	fileID := r.PathValue("id")

	file, err := models.Files.Get(fileID)
	if err != nil {
		c.jsonError(w, "File not found", http.StatusNotFound)
		return
	}

	// Only owner or admin can delete
	if user.ID != file.UserID && user.Role != "admin" {
		c.jsonError(w, "File not found", http.StatusNotFound)
		return
	}

	// Delete any message attachments first
	mfs, _ := models.MessageFiles.Search("WHERE FileID = ?", file.ID)
	for _, mf := range mfs {
		models.MessageFiles.Delete(mf)
	}

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

	helpers.AuditDelete(r, user.ID, helpers.ResourceFile, fileID, file.Name)

	content.SetCORSHeaders(w, r)

	w.WriteHeader(http.StatusNoContent)
}

// --- API Documentation ---

// DocsUI serves the Swagger UI for API documentation.
// GET /api/docs
func (c *APIController) DocsUI(w http.ResponseWriter, r *http.Request) {
	html := `<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ReadySite API Documentation</title>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.11.0/swagger-ui.css">
    <style>
        body { margin: 0; padding: 0; }
        .swagger-ui .topbar { display: none; }
    </style>
</head>
<body>
    <div id="swagger-ui"></div>
    <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.11.0/swagger-ui-bundle.js"></script>
    <script>
        window.onload = function() {
            window.ui = SwaggerUIBundle({
                url: "/api/docs/openapi.json",
                dom_id: '#swagger-ui',
                deepLinking: true,
                presets: [
                    SwaggerUIBundle.presets.apis,
                    SwaggerUIBundle.SwaggerUIStandalonePreset
                ],
                layout: "BaseLayout",
                persistAuthorization: true
            });
        };
    </script>
</body>
</html>`

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(html))
}

// OpenAPISpec serves the OpenAPI JSON specification.
// GET /api/docs/openapi.json
func (c *APIController) OpenAPISpec(w http.ResponseWriter, r *http.Request) {
	scheme := "http"
	if r.TLS != nil {
		scheme = "https"
	}
	if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" {
		scheme = proto
	}
	baseURL := scheme + "://" + r.Host

	spec, err := content.GenerateSpec(baseURL)
	if err != nil {
		c.jsonError(w, "Failed to generate API documentation", http.StatusInternalServerError)
		return
	}

	data, err := spec.ToJSON()
	if err != nil {
		c.jsonError(w, "Failed to serialize API documentation", http.StatusInternalServerError)
		return
	}

	content.SetCORSHeaders(w, r)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write(data)
}

// --- User API Endpoints ---

// UserResponse is the response format for user data.
type UserResponse struct {
	ID       string `json:"id"`
	Email    string `json:"email"`
	Name     string `json:"name"`
	Role     string `json:"role"`
	Verified bool   `json:"verified"`
	Created  string `json:"created"`
	Updated  string `json:"updated"`
}

// ListUsersResponse is the response format for listing users.
type ListUsersResponse struct {
	Page       int            `json:"page"`
	PerPage    int            `json:"perPage"`
	TotalItems int            `json:"totalItems"`
	TotalPages int            `json:"totalPages"`
	Items      []UserResponse `json:"items"`
}

// userToResponse converts a User model to API response format.
func (c *APIController) userToResponse(u *models.User) UserResponse {
	return UserResponse{
		ID:       u.ID,
		Email:    u.Email,
		Name:     u.Name,
		Role:     u.Role,
		Verified: u.Verified,
		Created:  u.CreatedAt.Format("2006-01-02T15:04:05Z"),
		Updated:  u.UpdatedAt.Format("2006-01-02T15:04:05Z"),
	}
}

// ListUsers returns paginated users.
// GET /api/users
func (c *APIController) ListUsers(w http.ResponseWriter, r *http.Request) {
	if !content.CheckRateLimit(r) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	user := access.GetUserFromJWT(r)
	if user == nil || user.Role != "admin" {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Admin access required", http.StatusForbidden)
		return
	}

	page := c.intParam(r, "page", 1)
	perPage := c.intParam(r, "perPage", 20)

	if page < 1 {
		page = 1
	}
	if perPage < 1 {
		perPage = 20
	}
	if perPage > 100 {
		perPage = 100
	}

	offset := (page - 1) * perPage

	totalItems := models.Users.Count("")
	totalPages := (totalItems + perPage - 1) / perPage
	if totalPages < 1 {
		totalPages = 1
	}

	users, err := models.Users.Search("ORDER BY CreatedAt DESC LIMIT ? OFFSET ?", perPage, offset)
	if err != nil {
		c.jsonError(w, "Failed to fetch users", http.StatusInternalServerError)
		return
	}

	items := make([]UserResponse, len(users))
	for i, u := range users {
		items[i] = c.userToResponse(u)
	}

	content.SetCORSHeaders(w, r)

	c.jsonResponse(w, ListUsersResponse{
		Page:       page,
		PerPage:    perPage,
		TotalItems: totalItems,
		TotalPages: totalPages,
		Items:      items,
	}, http.StatusOK)
}

// CreateUser creates a new user.
// POST /api/users
func (c *APIController) CreateUser(w http.ResponseWriter, r *http.Request) {
	// Rate limit by IP for signup
	key := r.RemoteAddr
	if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
		key = ip
	}
	if !access.AuthLimiter.Allow(key) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	user := access.GetUserFromJWT(r)

	// Check if public signup is allowed, or if user is admin
	signupEnabled := helpers.GetSetting(models.SettingSignupEnabled) == "true"
	isAdmin := user != nil && user.Role == "admin"

	if !signupEnabled && !isAdmin {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Public signup is not enabled", http.StatusForbidden)
		return
	}

	var req struct {
		Email    string `json:"email"`
		Password string `json:"password"`
		Name     string `json:"name"`
		Role     string `json:"role"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		c.jsonError(w, "Invalid JSON body", http.StatusBadRequest)
		return
	}

	if req.Email == "" {
		c.jsonError(w, "Email is required", http.StatusBadRequest)
		return
	}

	if req.Password == "" {
		c.jsonError(w, "Password is required", http.StatusBadRequest)
		return
	}

	if len(req.Password) < 8 {
		c.jsonError(w, "Password must be at least 8 characters", http.StatusBadRequest)
		return
	}

	// Validate email format
	if err := content.ValidateEmail(req.Email); err != nil {
		c.jsonError(w, "Invalid email format", http.StatusBadRequest)
		return
	}

	// Only admins can set role; public signup gets "user" role
	role := "user"
	if isAdmin && req.Role != "" {
		if req.Role == "admin" || req.Role == "user" || req.Role == "viewer" {
			role = req.Role
		}
	}

	// Check if email already exists
	existing, _ := models.Users.First("WHERE Email = ?", req.Email)
	if existing != nil {
		c.jsonError(w, "A user with this email already exists", http.StatusConflict)
		return
	}

	// Hash password
	hash, err := access.HashPassword(req.Password)
	if err != nil {
		c.jsonError(w, "Failed to process password", http.StatusInternalServerError)
		return
	}

	newUser := &models.User{
		Email:        req.Email,
		Name:         req.Name,
		PasswordHash: hash,
		Role:         role,
	}

	userID, err := models.Users.Insert(newUser)
	if err != nil {
		c.jsonError(w, "Failed to create user", http.StatusInternalServerError)
		return
	}

	newUser, _ = models.Users.Get(userID)
	if newUser == nil {
		c.jsonError(w, "Failed to retrieve user", http.StatusInternalServerError)
		return
	}

	// Audit log
	actorID := helpers.UserAnonymous
	if user != nil {
		actorID = user.ID
	}
	helpers.AuditCreate(r, actorID, helpers.ResourceUser, userID, req.Email)

	content.SetCORSHeaders(w, r)

	c.jsonResponse(w, c.userToResponse(newUser), http.StatusCreated)
}

// ViewUser returns a single user by ID.
// GET /api/users/{id}
func (c *APIController) ViewUser(w http.ResponseWriter, r *http.Request) {
	if !content.CheckRateLimit(r) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	userID := r.PathValue("id")

	currentUser := access.GetUserFromJWT(r)
	if currentUser == nil {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Authentication required", http.StatusUnauthorized)
		return
	}

	// Users can view themselves, admins can view anyone
	if currentUser.ID != userID && currentUser.Role != "admin" {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "User not found", http.StatusNotFound)
		return
	}

	user, err := models.Users.Get(userID)
	if err != nil {
		c.jsonError(w, "User not found", http.StatusNotFound)
		return
	}

	content.SetCORSHeaders(w, r)

	c.jsonResponse(w, c.userToResponse(user), http.StatusOK)
}

// UpdateUser updates an existing user.
// PATCH /api/users/{id}
func (c *APIController) UpdateUser(w http.ResponseWriter, r *http.Request) {
	if !content.CheckRateLimit(r) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	userID := r.PathValue("id")

	currentUser := access.GetUserFromJWT(r)
	if currentUser == nil {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Authentication required", http.StatusUnauthorized)
		return
	}

	// Users can update themselves, admins can update anyone
	if currentUser.ID != userID && currentUser.Role != "admin" {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "User not found", http.StatusNotFound)
		return
	}

	user, err := models.Users.Get(userID)
	if err != nil {
		c.jsonError(w, "User not found", http.StatusNotFound)
		return
	}

	var updates struct {
		Email    *string `json:"email"`
		Name     *string `json:"name"`
		Password *string `json:"password"`
		Role     *string `json:"role"`
	}
	if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
		c.jsonError(w, "Invalid JSON body", http.StatusBadRequest)
		return
	}

	if updates.Email != nil {
		if *updates.Email == "" {
			c.jsonError(w, "Email cannot be empty", http.StatusBadRequest)
			return
		}
		if err := content.ValidateEmail(*updates.Email); err != nil {
			c.jsonError(w, "Invalid email format", http.StatusBadRequest)
			return
		}
		// Check if email is taken by another user
		existing, _ := models.Users.First("WHERE Email = ? AND ID != ?", *updates.Email, userID)
		if existing != nil {
			c.jsonError(w, "A user with this email already exists", http.StatusConflict)
			return
		}
		user.Email = *updates.Email
	}

	if updates.Name != nil {
		user.Name = *updates.Name
	}

	if updates.Password != nil && *updates.Password != "" {
		if len(*updates.Password) < 8 {
			c.jsonError(w, "Password must be at least 8 characters", http.StatusBadRequest)
			return
		}
		hash, err := access.HashPassword(*updates.Password)
		if err != nil {
			c.jsonError(w, "Failed to process password", http.StatusInternalServerError)
			return
		}
		user.PasswordHash = hash
	}

	// Only admins can change roles
	if updates.Role != nil && currentUser.Role == "admin" {
		if *updates.Role == "admin" || *updates.Role == "user" || *updates.Role == "viewer" {
			user.Role = *updates.Role
		}
	}

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

	helpers.AuditUpdate(r, currentUser.ID, helpers.ResourceUser, userID, user.Email, nil)

	content.SetCORSHeaders(w, r)

	c.jsonResponse(w, c.userToResponse(user), http.StatusOK)
}

// DeleteUser deletes a user.
// DELETE /api/users/{id}
func (c *APIController) DeleteUser(w http.ResponseWriter, r *http.Request) {
	if !content.CheckRateLimit(r) {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Too many requests", http.StatusTooManyRequests)
		return
	}

	userID := r.PathValue("id")

	currentUser := access.GetUserFromJWT(r)
	if currentUser == nil || currentUser.Role != "admin" {
		content.SetCORSHeaders(w, r)
		c.jsonError(w, "Admin access required", http.StatusForbidden)
		return
	}

	// Prevent self-deletion
	if currentUser.ID == userID {
		c.jsonError(w, "You cannot delete your own account", http.StatusBadRequest)
		return
	}

	user, err := models.Users.Get(userID)
	if err != nil {
		c.jsonError(w, "User not found", http.StatusNotFound)
		return
	}

	// Prevent deleting the last admin
	if user.Role == "admin" {
		adminCount := models.Users.Count("WHERE Role = 'admin'")
		if adminCount <= 1 {
			c.jsonError(w, "Cannot delete the last admin user", http.StatusBadRequest)
			return
		}
	}

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

	helpers.AuditDelete(r, currentUser.ID, helpers.ResourceUser, userID, user.Email)

	content.SetCORSHeaders(w, r)

	w.WriteHeader(http.StatusNoContent)
}
← Back