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:
Trey t
2025-12-05 09:07:53 -06:00
parent e824c90877
commit bbf3999c79
17 changed files with 1634 additions and 4 deletions

View File

@@ -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

View 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
}

View File

@@ -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)
}