Add task templates API and admin management
- Add TaskTemplate model with category and frequency support - Add task template repository with CRUD and search operations - Add task template service layer - Add public API endpoints for templates (no auth required): - GET /api/tasks/templates/ - list all templates - GET /api/tasks/templates/grouped/ - templates grouped by category - GET /api/tasks/templates/search/?q= - search templates - GET /api/tasks/templates/by-category/:id/ - templates by category - GET /api/tasks/templates/:id/ - single template - Add admin panel for task template management (CRUD) - Add admin API endpoints for templates - Add seed file with predefined task templates - Add i18n translations for template errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -85,13 +85,21 @@ func (h *AdminSettingsHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
|
||||
// SeedLookups handles POST /api/admin/settings/seed-lookups
|
||||
// Seeds both lookup tables AND task templates
|
||||
func (h *AdminSettingsHandler) SeedLookups(c *gin.Context) {
|
||||
// First seed lookup tables
|
||||
if err := h.runSeedFile("001_lookups.sql"); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed lookups: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Lookup data seeded successfully"})
|
||||
// Then seed task templates
|
||||
if err := h.runSeedFile("003_task_templates.sql"); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed task templates: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Lookup data and task templates seeded successfully"})
|
||||
}
|
||||
|
||||
// SeedTestData handles POST /api/admin/settings/seed-test-data
|
||||
@@ -104,6 +112,16 @@ func (h *AdminSettingsHandler) SeedTestData(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Test data seeded successfully"})
|
||||
}
|
||||
|
||||
// SeedTaskTemplates handles POST /api/admin/settings/seed-task-templates
|
||||
func (h *AdminSettingsHandler) SeedTaskTemplates(c *gin.Context) {
|
||||
if err := h.runSeedFile("003_task_templates.sql"); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to seed task templates: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Task templates seeded successfully"})
|
||||
}
|
||||
|
||||
// runSeedFile executes a seed SQL file
|
||||
func (h *AdminSettingsHandler) runSeedFile(filename string) error {
|
||||
// Check multiple possible locations
|
||||
|
||||
321
internal/admin/handlers/task_template_handler.go
Normal file
321
internal/admin/handlers/task_template_handler.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
)
|
||||
|
||||
// AdminTaskTemplateHandler handles admin task template management endpoints
|
||||
type AdminTaskTemplateHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAdminTaskTemplateHandler creates a new admin task template handler
|
||||
func NewAdminTaskTemplateHandler(db *gorm.DB) *AdminTaskTemplateHandler {
|
||||
return &AdminTaskTemplateHandler{db: db}
|
||||
}
|
||||
|
||||
// TaskTemplateResponse represents a task template in admin responses
|
||||
type TaskTemplateResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
Category *TaskCategoryResponse `json:"category,omitempty"`
|
||||
FrequencyID *uint `json:"frequency_id"`
|
||||
Frequency *TaskFrequencyResponse `json:"frequency,omitempty"`
|
||||
IconIOS string `json:"icon_ios"`
|
||||
IconAndroid string `json:"icon_android"`
|
||||
Tags string `json:"tags"`
|
||||
DisplayOrder int `json:"display_order"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// CreateUpdateTaskTemplateRequest represents the request body for creating/updating templates
|
||||
type CreateUpdateTaskTemplateRequest struct {
|
||||
Title string `json:"title" binding:"required,max=200"`
|
||||
Description string `json:"description"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
FrequencyID *uint `json:"frequency_id"`
|
||||
IconIOS string `json:"icon_ios" binding:"max=100"`
|
||||
IconAndroid string `json:"icon_android" binding:"max=100"`
|
||||
Tags string `json:"tags"`
|
||||
DisplayOrder *int `json:"display_order"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// ListTemplates handles GET /admin/api/task-templates/
|
||||
func (h *AdminTaskTemplateHandler) ListTemplates(c *gin.Context) {
|
||||
var templates []models.TaskTemplate
|
||||
query := h.db.Preload("Category").Preload("Frequency").Order("display_order ASC, title ASC")
|
||||
|
||||
// Optional filter by active status
|
||||
if activeParam := c.Query("is_active"); activeParam != "" {
|
||||
isActive := activeParam == "true"
|
||||
query = query.Where("is_active = ?", isActive)
|
||||
}
|
||||
|
||||
// Optional filter by category
|
||||
if categoryID := c.Query("category_id"); categoryID != "" {
|
||||
query = query.Where("category_id = ?", categoryID)
|
||||
}
|
||||
|
||||
// Optional filter by frequency
|
||||
if frequencyID := c.Query("frequency_id"); frequencyID != "" {
|
||||
query = query.Where("frequency_id = ?", frequencyID)
|
||||
}
|
||||
|
||||
// Optional search
|
||||
if search := c.Query("search"); search != "" {
|
||||
searchTerm := "%" + strings.ToLower(search) + "%"
|
||||
query = query.Where("LOWER(title) LIKE ? OR LOWER(tags) LIKE ?", searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
if err := query.Find(&templates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"})
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]TaskTemplateResponse, len(templates))
|
||||
for i, t := range templates {
|
||||
responses[i] = h.toResponse(&t)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": responses, "total": len(responses)})
|
||||
}
|
||||
|
||||
// GetTemplate handles GET /admin/api/task-templates/:id/
|
||||
func (h *AdminTaskTemplateHandler) GetTemplate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var template models.TaskTemplate
|
||||
if err := h.db.Preload("Category").Preload("Frequency").First(&template, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, h.toResponse(&template))
|
||||
}
|
||||
|
||||
// CreateTemplate handles POST /admin/api/task-templates/
|
||||
func (h *AdminTaskTemplateHandler) CreateTemplate(c *gin.Context) {
|
||||
var req CreateUpdateTaskTemplateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
template := models.TaskTemplate{
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
CategoryID: req.CategoryID,
|
||||
FrequencyID: req.FrequencyID,
|
||||
IconIOS: req.IconIOS,
|
||||
IconAndroid: req.IconAndroid,
|
||||
Tags: req.Tags,
|
||||
IsActive: true, // Default to active
|
||||
}
|
||||
|
||||
if req.DisplayOrder != nil {
|
||||
template.DisplayOrder = *req.DisplayOrder
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
template.IsActive = *req.IsActive
|
||||
}
|
||||
|
||||
if err := h.db.Create(&template).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create template"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reload with preloads
|
||||
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
|
||||
|
||||
c.JSON(http.StatusCreated, h.toResponse(&template))
|
||||
}
|
||||
|
||||
// UpdateTemplate handles PUT /admin/api/task-templates/:id/
|
||||
func (h *AdminTaskTemplateHandler) UpdateTemplate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var template models.TaskTemplate
|
||||
if err := h.db.First(&template, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateUpdateTaskTemplateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
template.Title = req.Title
|
||||
template.Description = req.Description
|
||||
template.CategoryID = req.CategoryID
|
||||
template.FrequencyID = req.FrequencyID
|
||||
template.IconIOS = req.IconIOS
|
||||
template.IconAndroid = req.IconAndroid
|
||||
template.Tags = req.Tags
|
||||
|
||||
if req.DisplayOrder != nil {
|
||||
template.DisplayOrder = *req.DisplayOrder
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
template.IsActive = *req.IsActive
|
||||
}
|
||||
|
||||
if err := h.db.Save(&template).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reload with preloads
|
||||
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
|
||||
|
||||
c.JSON(http.StatusOK, h.toResponse(&template))
|
||||
}
|
||||
|
||||
// DeleteTemplate handles DELETE /admin/api/task-templates/:id/
|
||||
func (h *AdminTaskTemplateHandler) DeleteTemplate(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&models.TaskTemplate{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete template"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Template deleted successfully"})
|
||||
}
|
||||
|
||||
// ToggleActive handles POST /admin/api/task-templates/:id/toggle-active/
|
||||
func (h *AdminTaskTemplateHandler) ToggleActive(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var template models.TaskTemplate
|
||||
if err := h.db.First(&template, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch template"})
|
||||
return
|
||||
}
|
||||
|
||||
template.IsActive = !template.IsActive
|
||||
if err := h.db.Save(&template).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update template"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reload with preloads
|
||||
h.db.Preload("Category").Preload("Frequency").First(&template, template.ID)
|
||||
|
||||
c.JSON(http.StatusOK, h.toResponse(&template))
|
||||
}
|
||||
|
||||
// BulkCreate handles POST /admin/api/task-templates/bulk/
|
||||
func (h *AdminTaskTemplateHandler) BulkCreate(c *gin.Context) {
|
||||
var req struct {
|
||||
Templates []CreateUpdateTaskTemplateRequest `json:"templates" binding:"required,dive"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
templates := make([]models.TaskTemplate, len(req.Templates))
|
||||
for i, t := range req.Templates {
|
||||
templates[i] = models.TaskTemplate{
|
||||
Title: t.Title,
|
||||
Description: t.Description,
|
||||
CategoryID: t.CategoryID,
|
||||
FrequencyID: t.FrequencyID,
|
||||
IconIOS: t.IconIOS,
|
||||
IconAndroid: t.IconAndroid,
|
||||
Tags: t.Tags,
|
||||
IsActive: true,
|
||||
}
|
||||
if t.DisplayOrder != nil {
|
||||
templates[i].DisplayOrder = *t.DisplayOrder
|
||||
}
|
||||
if t.IsActive != nil {
|
||||
templates[i].IsActive = *t.IsActive
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.db.Create(&templates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create templates"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"message": "Templates created successfully", "count": len(templates)})
|
||||
}
|
||||
|
||||
// Helper to convert model to response
|
||||
func (h *AdminTaskTemplateHandler) toResponse(t *models.TaskTemplate) TaskTemplateResponse {
|
||||
resp := TaskTemplateResponse{
|
||||
ID: t.ID,
|
||||
Title: t.Title,
|
||||
Description: t.Description,
|
||||
CategoryID: t.CategoryID,
|
||||
FrequencyID: t.FrequencyID,
|
||||
IconIOS: t.IconIOS,
|
||||
IconAndroid: t.IconAndroid,
|
||||
Tags: t.Tags,
|
||||
DisplayOrder: t.DisplayOrder,
|
||||
IsActive: t.IsActive,
|
||||
}
|
||||
|
||||
if t.Category != nil {
|
||||
resp.Category = &TaskCategoryResponse{
|
||||
ID: t.Category.ID,
|
||||
Name: t.Category.Name,
|
||||
Description: t.Category.Description,
|
||||
Icon: t.Category.Icon,
|
||||
Color: t.Category.Color,
|
||||
DisplayOrder: t.Category.DisplayOrder,
|
||||
}
|
||||
}
|
||||
|
||||
if t.Frequency != nil {
|
||||
resp.Frequency = &TaskFrequencyResponse{
|
||||
ID: t.Frequency.ID,
|
||||
Name: t.Frequency.Name,
|
||||
Days: t.Frequency.Days,
|
||||
DisplayOrder: t.Frequency.DisplayOrder,
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
@@ -297,6 +297,19 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
|
||||
specialties.DELETE("/:id", lookupHandler.DeleteSpecialty)
|
||||
}
|
||||
|
||||
// Task Templates management
|
||||
taskTemplateHandler := handlers.NewAdminTaskTemplateHandler(db)
|
||||
taskTemplates := protected.Group("/task-templates")
|
||||
{
|
||||
taskTemplates.GET("", taskTemplateHandler.ListTemplates)
|
||||
taskTemplates.POST("", taskTemplateHandler.CreateTemplate)
|
||||
taskTemplates.POST("/bulk", taskTemplateHandler.BulkCreate)
|
||||
taskTemplates.GET("/:id", taskTemplateHandler.GetTemplate)
|
||||
taskTemplates.PUT("/:id", taskTemplateHandler.UpdateTemplate)
|
||||
taskTemplates.DELETE("/:id", taskTemplateHandler.DeleteTemplate)
|
||||
taskTemplates.POST("/:id/toggle-active", taskTemplateHandler.ToggleActive)
|
||||
}
|
||||
|
||||
// Admin user management
|
||||
adminUserHandler := handlers.NewAdminUserManagementHandler(db)
|
||||
adminUsers := protected.Group("/admin-users")
|
||||
@@ -327,6 +340,7 @@ func SetupRoutes(router *gin.Engine, db *gorm.DB, cfg *config.Config, deps *Depe
|
||||
settings.PUT("", settingsHandler.UpdateSettings)
|
||||
settings.POST("/seed-lookups", settingsHandler.SeedLookups)
|
||||
settings.POST("/seed-test-data", settingsHandler.SeedTestData)
|
||||
settings.POST("/seed-task-templates", settingsHandler.SeedTaskTemplates)
|
||||
settings.POST("/clear-all-data", settingsHandler.ClearAllData)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user