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

View File

@@ -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{},

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

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

View File

@@ -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}}",

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

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

View File

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

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