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)
|
||||
}
|
||||
|
||||
|
||||
@@ -116,6 +116,7 @@ func Migrate() error {
|
||||
&models.TaskFrequency{},
|
||||
&models.TaskStatus{},
|
||||
&models.ContractorSpecialty{},
|
||||
&models.TaskTemplate{}, // Task templates reference category and frequency
|
||||
|
||||
// User and auth tables
|
||||
&models.User{},
|
||||
|
||||
134
internal/dto/responses/task_template.go
Normal file
134
internal/dto/responses/task_template.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package responses
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
)
|
||||
|
||||
// TaskTemplateResponse represents a task template in the API response
|
||||
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"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskTemplateCategoryGroup represents templates grouped by category
|
||||
type TaskTemplateCategoryGroup struct {
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryID *uint `json:"category_id"`
|
||||
Templates []TaskTemplateResponse `json:"templates"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// TaskTemplatesGroupedResponse represents all templates grouped by category
|
||||
type TaskTemplatesGroupedResponse struct {
|
||||
Categories []TaskTemplateCategoryGroup `json:"categories"`
|
||||
TotalCount int `json:"total_count"`
|
||||
}
|
||||
|
||||
// NewTaskTemplateResponse creates a TaskTemplateResponse from a model
|
||||
func NewTaskTemplateResponse(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: parseTags(t.Tags),
|
||||
DisplayOrder: t.DisplayOrder,
|
||||
IsActive: t.IsActive,
|
||||
CreatedAt: t.CreatedAt,
|
||||
UpdatedAt: t.UpdatedAt,
|
||||
}
|
||||
|
||||
if t.Category != nil {
|
||||
resp.Category = NewTaskCategoryResponse(t.Category)
|
||||
}
|
||||
if t.Frequency != nil {
|
||||
resp.Frequency = NewTaskFrequencyResponse(t.Frequency)
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// NewTaskTemplateListResponse creates a list of task template responses
|
||||
func NewTaskTemplateListResponse(templates []models.TaskTemplate) []TaskTemplateResponse {
|
||||
results := make([]TaskTemplateResponse, len(templates))
|
||||
for i, t := range templates {
|
||||
results[i] = NewTaskTemplateResponse(&t)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// NewTaskTemplatesGroupedResponse creates a grouped response from templates
|
||||
func NewTaskTemplatesGroupedResponse(templates []models.TaskTemplate) TaskTemplatesGroupedResponse {
|
||||
// Group by category
|
||||
categoryMap := make(map[string]*TaskTemplateCategoryGroup)
|
||||
categoryOrder := []string{} // To maintain order
|
||||
|
||||
for _, t := range templates {
|
||||
categoryName := "Uncategorized"
|
||||
var categoryID *uint
|
||||
if t.Category != nil {
|
||||
categoryName = t.Category.Name
|
||||
categoryID = &t.Category.ID
|
||||
}
|
||||
|
||||
if _, exists := categoryMap[categoryName]; !exists {
|
||||
categoryMap[categoryName] = &TaskTemplateCategoryGroup{
|
||||
CategoryName: categoryName,
|
||||
CategoryID: categoryID,
|
||||
Templates: []TaskTemplateResponse{},
|
||||
}
|
||||
categoryOrder = append(categoryOrder, categoryName)
|
||||
}
|
||||
|
||||
categoryMap[categoryName].Templates = append(categoryMap[categoryName].Templates, NewTaskTemplateResponse(&t))
|
||||
}
|
||||
|
||||
// Build ordered result
|
||||
categories := make([]TaskTemplateCategoryGroup, len(categoryOrder))
|
||||
totalCount := 0
|
||||
for i, name := range categoryOrder {
|
||||
group := categoryMap[name]
|
||||
group.Count = len(group.Templates)
|
||||
totalCount += group.Count
|
||||
categories[i] = *group
|
||||
}
|
||||
|
||||
return TaskTemplatesGroupedResponse{
|
||||
Categories: categories,
|
||||
TotalCount: totalCount,
|
||||
}
|
||||
}
|
||||
|
||||
// parseTags splits a comma-separated tags string into a slice
|
||||
func parseTags(tags string) []string {
|
||||
if tags == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(tags, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
106
internal/handlers/task_template_handler.go
Normal file
106
internal/handlers/task_template_handler.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/i18n"
|
||||
"github.com/treytartt/casera-api/internal/services"
|
||||
)
|
||||
|
||||
// TaskTemplateHandler handles task template endpoints
|
||||
type TaskTemplateHandler struct {
|
||||
templateService *services.TaskTemplateService
|
||||
}
|
||||
|
||||
// NewTaskTemplateHandler creates a new task template handler
|
||||
func NewTaskTemplateHandler(templateService *services.TaskTemplateService) *TaskTemplateHandler {
|
||||
return &TaskTemplateHandler{
|
||||
templateService: templateService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetTemplates handles GET /api/tasks/templates/
|
||||
// Returns all active task templates as a flat list
|
||||
func (h *TaskTemplateHandler) GetTemplates(c *gin.Context) {
|
||||
templates, err := h.templateService.GetAll()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, templates)
|
||||
}
|
||||
|
||||
// GetTemplatesGrouped handles GET /api/tasks/templates/grouped/
|
||||
// Returns all templates grouped by category
|
||||
func (h *TaskTemplateHandler) GetTemplatesGrouped(c *gin.Context) {
|
||||
grouped, err := h.templateService.GetGrouped()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, grouped)
|
||||
}
|
||||
|
||||
// SearchTemplates handles GET /api/tasks/templates/search/
|
||||
// Searches templates by query string
|
||||
func (h *TaskTemplateHandler) SearchTemplates(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter 'q' is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if len(query) < 2 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Query must be at least 2 characters"})
|
||||
return
|
||||
}
|
||||
|
||||
templates, err := h.templateService.Search(query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_search_templates")})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, templates)
|
||||
}
|
||||
|
||||
// GetTemplatesByCategory handles GET /api/tasks/templates/by-category/:category_id/
|
||||
// Returns templates for a specific category
|
||||
func (h *TaskTemplateHandler) GetTemplatesByCategory(c *gin.Context) {
|
||||
categoryID, err := strconv.ParseUint(c.Param("category_id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"})
|
||||
return
|
||||
}
|
||||
|
||||
templates, err := h.templateService.GetByCategory(uint(categoryID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": i18n.LocalizedMessage(c, "error.failed_to_fetch_templates")})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, templates)
|
||||
}
|
||||
|
||||
// GetTemplate handles GET /api/tasks/templates/:id/
|
||||
// Returns a single template by ID
|
||||
func (h *TaskTemplateHandler) 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
|
||||
}
|
||||
|
||||
template, err := h.templateService.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": i18n.LocalizedMessage(c, "error.template_not_found")})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, template)
|
||||
}
|
||||
@@ -114,6 +114,9 @@
|
||||
"error.failed_to_fetch_task_frequencies": "Failed to fetch task frequencies",
|
||||
"error.failed_to_fetch_task_statuses": "Failed to fetch task statuses",
|
||||
"error.failed_to_fetch_contractor_specialties": "Failed to fetch contractor specialties",
|
||||
"error.failed_to_fetch_templates": "Failed to fetch task templates",
|
||||
"error.failed_to_search_templates": "Failed to search task templates",
|
||||
"error.template_not_found": "Task template not found",
|
||||
|
||||
"push.task_due_soon.title": "Task Due Soon",
|
||||
"push.task_due_soon.body": "{{.TaskTitle}} is due {{.DueDate}}",
|
||||
|
||||
22
internal/models/task_template.go
Normal file
22
internal/models/task_template.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package models
|
||||
|
||||
// TaskTemplate represents a predefined task template that users can select when creating tasks
|
||||
type TaskTemplate struct {
|
||||
BaseModel
|
||||
Title string `gorm:"column:title;size:200;not null" json:"title"`
|
||||
Description string `gorm:"column:description;type:text" json:"description"`
|
||||
CategoryID *uint `gorm:"column:category_id;index" json:"category_id"`
|
||||
Category *TaskCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
FrequencyID *uint `gorm:"column:frequency_id;index" json:"frequency_id"`
|
||||
Frequency *TaskFrequency `gorm:"foreignKey:FrequencyID" json:"frequency,omitempty"`
|
||||
IconIOS string `gorm:"column:icon_ios;size:100" json:"icon_ios"`
|
||||
IconAndroid string `gorm:"column:icon_android;size:100" json:"icon_android"`
|
||||
Tags string `gorm:"column:tags;type:text" json:"tags"` // Comma-separated tags for search
|
||||
DisplayOrder int `gorm:"column:display_order;default:0" json:"display_order"`
|
||||
IsActive bool `gorm:"column:is_active;default:true;index" json:"is_active"`
|
||||
}
|
||||
|
||||
// TableName returns the table name for GORM
|
||||
func (TaskTemplate) TableName() string {
|
||||
return "task_tasktemplate"
|
||||
}
|
||||
123
internal/repositories/task_template_repo.go
Normal file
123
internal/repositories/task_template_repo.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/casera-api/internal/models"
|
||||
)
|
||||
|
||||
// TaskTemplateRepository handles database operations for task templates
|
||||
type TaskTemplateRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewTaskTemplateRepository creates a new task template repository
|
||||
func NewTaskTemplateRepository(db *gorm.DB) *TaskTemplateRepository {
|
||||
return &TaskTemplateRepository{db: db}
|
||||
}
|
||||
|
||||
// GetAll returns all active task templates ordered by category and display order
|
||||
func (r *TaskTemplateRepository) GetAll() ([]models.TaskTemplate, error) {
|
||||
var templates []models.TaskTemplate
|
||||
err := r.db.
|
||||
Preload("Category").
|
||||
Preload("Frequency").
|
||||
Where("is_active = ?", true).
|
||||
Order("display_order ASC, title ASC").
|
||||
Find(&templates).Error
|
||||
return templates, err
|
||||
}
|
||||
|
||||
// GetByCategory returns all active templates for a specific category
|
||||
func (r *TaskTemplateRepository) GetByCategory(categoryID uint) ([]models.TaskTemplate, error) {
|
||||
var templates []models.TaskTemplate
|
||||
err := r.db.
|
||||
Preload("Category").
|
||||
Preload("Frequency").
|
||||
Where("is_active = ? AND category_id = ?", true, categoryID).
|
||||
Order("display_order ASC, title ASC").
|
||||
Find(&templates).Error
|
||||
return templates, err
|
||||
}
|
||||
|
||||
// Search searches templates by title and tags
|
||||
func (r *TaskTemplateRepository) Search(query string) ([]models.TaskTemplate, error) {
|
||||
var templates []models.TaskTemplate
|
||||
searchTerm := "%" + strings.ToLower(query) + "%"
|
||||
|
||||
err := r.db.
|
||||
Preload("Category").
|
||||
Preload("Frequency").
|
||||
Where("is_active = ? AND (LOWER(title) LIKE ? OR LOWER(tags) LIKE ?)", true, searchTerm, searchTerm).
|
||||
Order("display_order ASC, title ASC").
|
||||
Limit(20). // Limit search results
|
||||
Find(&templates).Error
|
||||
return templates, err
|
||||
}
|
||||
|
||||
// GetByID returns a single template by ID
|
||||
func (r *TaskTemplateRepository) GetByID(id uint) (*models.TaskTemplate, error) {
|
||||
var template models.TaskTemplate
|
||||
err := r.db.
|
||||
Preload("Category").
|
||||
Preload("Frequency").
|
||||
First(&template, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &template, nil
|
||||
}
|
||||
|
||||
// Create creates a new task template
|
||||
func (r *TaskTemplateRepository) Create(template *models.TaskTemplate) error {
|
||||
return r.db.Create(template).Error
|
||||
}
|
||||
|
||||
// Update updates an existing task template
|
||||
func (r *TaskTemplateRepository) Update(template *models.TaskTemplate) error {
|
||||
return r.db.Save(template).Error
|
||||
}
|
||||
|
||||
// Delete hard deletes a task template
|
||||
func (r *TaskTemplateRepository) Delete(id uint) error {
|
||||
return r.db.Delete(&models.TaskTemplate{}, id).Error
|
||||
}
|
||||
|
||||
// GetAllIncludingInactive returns all templates including inactive ones (for admin)
|
||||
func (r *TaskTemplateRepository) GetAllIncludingInactive() ([]models.TaskTemplate, error) {
|
||||
var templates []models.TaskTemplate
|
||||
err := r.db.
|
||||
Preload("Category").
|
||||
Preload("Frequency").
|
||||
Order("display_order ASC, title ASC").
|
||||
Find(&templates).Error
|
||||
return templates, err
|
||||
}
|
||||
|
||||
// Count returns the total count of active templates
|
||||
func (r *TaskTemplateRepository) Count() (int64, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&models.TaskTemplate{}).Where("is_active = ?", true).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetGroupedByCategory returns templates grouped by category name
|
||||
func (r *TaskTemplateRepository) GetGroupedByCategory() (map[string][]models.TaskTemplate, error) {
|
||||
templates, err := r.GetAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string][]models.TaskTemplate)
|
||||
for _, t := range templates {
|
||||
categoryName := "Uncategorized"
|
||||
if t.Category != nil {
|
||||
categoryName = t.Category.Name
|
||||
}
|
||||
result[categoryName] = append(result[categoryName], t)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -83,6 +83,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
documentRepo := repositories.NewDocumentRepository(deps.DB)
|
||||
notificationRepo := repositories.NewNotificationRepository(deps.DB)
|
||||
subscriptionRepo := repositories.NewSubscriptionRepository(deps.DB)
|
||||
taskTemplateRepo := repositories.NewTaskTemplateRepository(deps.DB)
|
||||
|
||||
// Initialize services
|
||||
authService := services.NewAuthService(userRepo, cfg)
|
||||
@@ -99,6 +100,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
taskService.SetNotificationService(notificationService)
|
||||
taskService.SetEmailService(deps.EmailService)
|
||||
subscriptionService := services.NewSubscriptionService(subscriptionRepo, residenceRepo, taskRepo, contractorRepo, documentRepo)
|
||||
taskTemplateService := services.NewTaskTemplateService(taskTemplateRepo)
|
||||
|
||||
// Initialize middleware
|
||||
authMiddleware := middleware.NewAuthMiddleware(deps.DB, deps.Cache)
|
||||
@@ -117,6 +119,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
notificationHandler := handlers.NewNotificationHandler(notificationService)
|
||||
subscriptionHandler := handlers.NewSubscriptionHandler(subscriptionService)
|
||||
staticDataHandler := handlers.NewStaticDataHandler(residenceService, taskService, contractorService)
|
||||
taskTemplateHandler := handlers.NewTaskTemplateHandler(taskTemplateService)
|
||||
|
||||
// Initialize upload handler (if storage service is available)
|
||||
var uploadHandler *handlers.UploadHandler
|
||||
@@ -140,7 +143,7 @@ func SetupRouter(deps *Dependencies) *gin.Engine {
|
||||
setupPublicAuthRoutes(api, authHandler)
|
||||
|
||||
// Public data routes (no auth required)
|
||||
setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler)
|
||||
setupPublicDataRoutes(api, residenceHandler, taskHandler, contractorHandler, staticDataHandler, subscriptionHandler, taskTemplateHandler)
|
||||
|
||||
// Protected routes (auth required)
|
||||
protected := api.Group("")
|
||||
@@ -220,7 +223,7 @@ func setupProtectedAuthRoutes(api *gin.RouterGroup, authHandler *handlers.AuthHa
|
||||
}
|
||||
|
||||
// setupPublicDataRoutes configures public data routes (lookups, static data)
|
||||
func setupPublicDataRoutes(api *gin.RouterGroup, residenceHandler *handlers.ResidenceHandler, taskHandler *handlers.TaskHandler, contractorHandler *handlers.ContractorHandler, staticDataHandler *handlers.StaticDataHandler, subscriptionHandler *handlers.SubscriptionHandler) {
|
||||
func setupPublicDataRoutes(api *gin.RouterGroup, residenceHandler *handlers.ResidenceHandler, taskHandler *handlers.TaskHandler, contractorHandler *handlers.ContractorHandler, staticDataHandler *handlers.StaticDataHandler, subscriptionHandler *handlers.SubscriptionHandler, taskTemplateHandler *handlers.TaskTemplateHandler) {
|
||||
// Static data routes (public, cached)
|
||||
staticData := api.Group("/static_data")
|
||||
{
|
||||
@@ -241,6 +244,16 @@ func setupPublicDataRoutes(api *gin.RouterGroup, residenceHandler *handlers.Resi
|
||||
api.GET("/tasks/frequencies/", taskHandler.GetFrequencies)
|
||||
api.GET("/tasks/statuses/", taskHandler.GetStatuses)
|
||||
api.GET("/contractors/specialties/", contractorHandler.GetSpecialties)
|
||||
|
||||
// Task template routes (public, for app autocomplete)
|
||||
templates := api.Group("/tasks/templates")
|
||||
{
|
||||
templates.GET("/", taskTemplateHandler.GetTemplates)
|
||||
templates.GET("/grouped/", taskTemplateHandler.GetTemplatesGrouped)
|
||||
templates.GET("/search/", taskTemplateHandler.SearchTemplates)
|
||||
templates.GET("/by-category/:category_id/", taskTemplateHandler.GetTemplatesByCategory)
|
||||
templates.GET("/:id/", taskTemplateHandler.GetTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
// setupResidenceRoutes configures residence routes
|
||||
|
||||
69
internal/services/task_template_service.go
Normal file
69
internal/services/task_template_service.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/treytartt/casera-api/internal/dto/responses"
|
||||
"github.com/treytartt/casera-api/internal/repositories"
|
||||
)
|
||||
|
||||
// TaskTemplateService handles business logic for task templates
|
||||
type TaskTemplateService struct {
|
||||
templateRepo *repositories.TaskTemplateRepository
|
||||
}
|
||||
|
||||
// NewTaskTemplateService creates a new task template service
|
||||
func NewTaskTemplateService(templateRepo *repositories.TaskTemplateRepository) *TaskTemplateService {
|
||||
return &TaskTemplateService{
|
||||
templateRepo: templateRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAll returns all active task templates
|
||||
func (s *TaskTemplateService) GetAll() ([]responses.TaskTemplateResponse, error) {
|
||||
templates, err := s.templateRepo.GetAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return responses.NewTaskTemplateListResponse(templates), nil
|
||||
}
|
||||
|
||||
// GetGrouped returns all templates grouped by category
|
||||
func (s *TaskTemplateService) GetGrouped() (responses.TaskTemplatesGroupedResponse, error) {
|
||||
templates, err := s.templateRepo.GetAll()
|
||||
if err != nil {
|
||||
return responses.TaskTemplatesGroupedResponse{}, err
|
||||
}
|
||||
return responses.NewTaskTemplatesGroupedResponse(templates), nil
|
||||
}
|
||||
|
||||
// Search searches templates by query string
|
||||
func (s *TaskTemplateService) Search(query string) ([]responses.TaskTemplateResponse, error) {
|
||||
templates, err := s.templateRepo.Search(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return responses.NewTaskTemplateListResponse(templates), nil
|
||||
}
|
||||
|
||||
// GetByCategory returns templates for a specific category
|
||||
func (s *TaskTemplateService) GetByCategory(categoryID uint) ([]responses.TaskTemplateResponse, error) {
|
||||
templates, err := s.templateRepo.GetByCategory(categoryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return responses.NewTaskTemplateListResponse(templates), nil
|
||||
}
|
||||
|
||||
// GetByID returns a single template by ID
|
||||
func (s *TaskTemplateService) GetByID(id uint) (*responses.TaskTemplateResponse, error) {
|
||||
template, err := s.templateRepo.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := responses.NewTaskTemplateResponse(template)
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// Count returns the total count of active templates
|
||||
func (s *TaskTemplateService) Count() (int64, error) {
|
||||
return s.templateRepo.Count()
|
||||
}
|
||||
Reference in New Issue
Block a user