- Push notification: sent to all users EXCEPT completer - Email: sent to ALL users INCLUDING completer 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
690 lines
17 KiB
Go
690 lines
17 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/treytartt/mycrib-api/internal/dto/requests"
|
|
"github.com/treytartt/mycrib-api/internal/dto/responses"
|
|
"github.com/treytartt/mycrib-api/internal/models"
|
|
"github.com/treytartt/mycrib-api/internal/repositories"
|
|
)
|
|
|
|
// Task-related errors
|
|
var (
|
|
ErrTaskNotFound = errors.New("task not found")
|
|
ErrTaskAccessDenied = errors.New("you do not have access to this task")
|
|
ErrTaskAlreadyCancelled = errors.New("task is already cancelled")
|
|
ErrTaskAlreadyArchived = errors.New("task is already archived")
|
|
ErrCompletionNotFound = errors.New("task completion not found")
|
|
)
|
|
|
|
// TaskService handles task business logic
|
|
type TaskService struct {
|
|
taskRepo *repositories.TaskRepository
|
|
residenceRepo *repositories.ResidenceRepository
|
|
notificationService *NotificationService
|
|
emailService *EmailService
|
|
}
|
|
|
|
// NewTaskService creates a new task service
|
|
func NewTaskService(taskRepo *repositories.TaskRepository, residenceRepo *repositories.ResidenceRepository) *TaskService {
|
|
return &TaskService{
|
|
taskRepo: taskRepo,
|
|
residenceRepo: residenceRepo,
|
|
}
|
|
}
|
|
|
|
// SetNotificationService sets the notification service (for breaking circular dependency)
|
|
func (s *TaskService) SetNotificationService(ns *NotificationService) {
|
|
s.notificationService = ns
|
|
}
|
|
|
|
// SetEmailService sets the email service
|
|
func (s *TaskService) SetEmailService(es *EmailService) {
|
|
s.emailService = es
|
|
}
|
|
|
|
// === Task CRUD ===
|
|
|
|
// GetTask gets a task by ID with access check
|
|
func (s *TaskService) GetTask(taskID, userID uint) (*responses.TaskResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrTaskNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Check access via residence
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hasAccess {
|
|
return nil, ErrTaskAccessDenied
|
|
}
|
|
|
|
resp := responses.NewTaskResponse(task)
|
|
return &resp, nil
|
|
}
|
|
|
|
// ListTasks lists all tasks accessible to a user
|
|
func (s *TaskService) ListTasks(userID uint) (*responses.TaskListResponse, error) {
|
|
// Get all residence IDs accessible to user
|
|
residences, err := s.residenceRepo.FindByUser(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
residenceIDs := make([]uint, len(residences))
|
|
for i, r := range residences {
|
|
residenceIDs[i] = r.ID
|
|
}
|
|
|
|
if len(residenceIDs) == 0 {
|
|
return &responses.TaskListResponse{Count: 0, Results: []responses.TaskResponse{}}, nil
|
|
}
|
|
|
|
tasks, err := s.taskRepo.FindByUser(userID, residenceIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := responses.NewTaskListResponse(tasks)
|
|
return &resp, nil
|
|
}
|
|
|
|
// GetTasksByResidence gets tasks for a specific residence (kanban board)
|
|
func (s *TaskService) GetTasksByResidence(residenceID, userID uint, daysThreshold int) (*responses.KanbanBoardResponse, error) {
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(residenceID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hasAccess {
|
|
return nil, ErrResidenceAccessDenied
|
|
}
|
|
|
|
if daysThreshold <= 0 {
|
|
daysThreshold = 30 // Default
|
|
}
|
|
|
|
board, err := s.taskRepo.GetKanbanData(residenceID, daysThreshold)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := responses.NewKanbanBoardResponse(board, residenceID)
|
|
return &resp, nil
|
|
}
|
|
|
|
// CreateTask creates a new task
|
|
func (s *TaskService) CreateTask(req *requests.CreateTaskRequest, userID uint) (*responses.TaskResponse, error) {
|
|
// Check residence access
|
|
hasAccess, err := s.residenceRepo.HasAccess(req.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hasAccess {
|
|
return nil, ErrResidenceAccessDenied
|
|
}
|
|
|
|
task := &models.Task{
|
|
ResidenceID: req.ResidenceID,
|
|
CreatedByID: userID,
|
|
Title: req.Title,
|
|
Description: req.Description,
|
|
CategoryID: req.CategoryID,
|
|
PriorityID: req.PriorityID,
|
|
StatusID: req.StatusID,
|
|
FrequencyID: req.FrequencyID,
|
|
AssignedToID: req.AssignedToID,
|
|
DueDate: req.DueDate,
|
|
EstimatedCost: req.EstimatedCost,
|
|
ContractorID: req.ContractorID,
|
|
}
|
|
|
|
if err := s.taskRepo.Create(task); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Reload with relations
|
|
task, err = s.taskRepo.FindByID(task.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := responses.NewTaskResponse(task)
|
|
return &resp, nil
|
|
}
|
|
|
|
// UpdateTask updates a task
|
|
func (s *TaskService) UpdateTask(taskID, userID uint, req *requests.UpdateTaskRequest) (*responses.TaskResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrTaskNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hasAccess {
|
|
return nil, ErrTaskAccessDenied
|
|
}
|
|
|
|
// Apply updates
|
|
if req.Title != nil {
|
|
task.Title = *req.Title
|
|
}
|
|
if req.Description != nil {
|
|
task.Description = *req.Description
|
|
}
|
|
if req.CategoryID != nil {
|
|
task.CategoryID = req.CategoryID
|
|
}
|
|
if req.PriorityID != nil {
|
|
task.PriorityID = req.PriorityID
|
|
}
|
|
if req.StatusID != nil {
|
|
task.StatusID = req.StatusID
|
|
}
|
|
if req.FrequencyID != nil {
|
|
task.FrequencyID = req.FrequencyID
|
|
}
|
|
if req.AssignedToID != nil {
|
|
task.AssignedToID = req.AssignedToID
|
|
}
|
|
if req.DueDate != nil {
|
|
task.DueDate = req.DueDate
|
|
}
|
|
if req.EstimatedCost != nil {
|
|
task.EstimatedCost = req.EstimatedCost
|
|
}
|
|
if req.ActualCost != nil {
|
|
task.ActualCost = req.ActualCost
|
|
}
|
|
if req.ContractorID != nil {
|
|
task.ContractorID = req.ContractorID
|
|
}
|
|
|
|
if err := s.taskRepo.Update(task); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(task.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := responses.NewTaskResponse(task)
|
|
return &resp, nil
|
|
}
|
|
|
|
// DeleteTask deletes a task
|
|
func (s *TaskService) DeleteTask(taskID, userID uint) error {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrTaskNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !hasAccess {
|
|
return ErrTaskAccessDenied
|
|
}
|
|
|
|
return s.taskRepo.Delete(taskID)
|
|
}
|
|
|
|
// === Task Actions ===
|
|
|
|
// MarkInProgress marks a task as in progress
|
|
func (s *TaskService) MarkInProgress(taskID, userID uint) (*responses.TaskResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrTaskNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hasAccess {
|
|
return nil, ErrTaskAccessDenied
|
|
}
|
|
|
|
// Find "In Progress" status
|
|
status, err := s.taskRepo.FindStatusByName("In Progress")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := s.taskRepo.MarkInProgress(taskID, status.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := responses.NewTaskResponse(task)
|
|
return &resp, nil
|
|
}
|
|
|
|
// CancelTask cancels a task
|
|
func (s *TaskService) CancelTask(taskID, userID uint) (*responses.TaskResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrTaskNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hasAccess {
|
|
return nil, ErrTaskAccessDenied
|
|
}
|
|
|
|
if task.IsCancelled {
|
|
return nil, ErrTaskAlreadyCancelled
|
|
}
|
|
|
|
if err := s.taskRepo.Cancel(taskID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := responses.NewTaskResponse(task)
|
|
return &resp, nil
|
|
}
|
|
|
|
// UncancelTask uncancels a task
|
|
func (s *TaskService) UncancelTask(taskID, userID uint) (*responses.TaskResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrTaskNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hasAccess {
|
|
return nil, ErrTaskAccessDenied
|
|
}
|
|
|
|
if err := s.taskRepo.Uncancel(taskID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := responses.NewTaskResponse(task)
|
|
return &resp, nil
|
|
}
|
|
|
|
// ArchiveTask archives a task
|
|
func (s *TaskService) ArchiveTask(taskID, userID uint) (*responses.TaskResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrTaskNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hasAccess {
|
|
return nil, ErrTaskAccessDenied
|
|
}
|
|
|
|
if task.IsArchived {
|
|
return nil, ErrTaskAlreadyArchived
|
|
}
|
|
|
|
if err := s.taskRepo.Archive(taskID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := responses.NewTaskResponse(task)
|
|
return &resp, nil
|
|
}
|
|
|
|
// UnarchiveTask unarchives a task
|
|
func (s *TaskService) UnarchiveTask(taskID, userID uint) (*responses.TaskResponse, error) {
|
|
task, err := s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrTaskNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hasAccess {
|
|
return nil, ErrTaskAccessDenied
|
|
}
|
|
|
|
if err := s.taskRepo.Unarchive(taskID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Reload
|
|
task, err = s.taskRepo.FindByID(taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := responses.NewTaskResponse(task)
|
|
return &resp, nil
|
|
}
|
|
|
|
// === Task Completions ===
|
|
|
|
// CreateCompletion creates a task completion
|
|
func (s *TaskService) CreateCompletion(req *requests.CreateTaskCompletionRequest, userID uint) (*responses.TaskCompletionResponse, error) {
|
|
// Get the task
|
|
task, err := s.taskRepo.FindByID(req.TaskID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrTaskNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hasAccess {
|
|
return nil, ErrTaskAccessDenied
|
|
}
|
|
|
|
completedAt := time.Now().UTC()
|
|
if req.CompletedAt != nil {
|
|
completedAt = *req.CompletedAt
|
|
}
|
|
|
|
completion := &models.TaskCompletion{
|
|
TaskID: req.TaskID,
|
|
CompletedByID: userID,
|
|
CompletedAt: completedAt,
|
|
Notes: req.Notes,
|
|
ActualCost: req.ActualCost,
|
|
PhotoURL: req.PhotoURL,
|
|
}
|
|
|
|
if err := s.taskRepo.CreateCompletion(completion); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Reload completion with user info
|
|
completion, err = s.taskRepo.FindCompletionByID(completion.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Send notification to residence owner and other users
|
|
s.sendTaskCompletedNotification(task, completion)
|
|
|
|
resp := responses.NewTaskCompletionResponse(completion)
|
|
return &resp, nil
|
|
}
|
|
|
|
// sendTaskCompletedNotification sends notifications when a task is completed
|
|
func (s *TaskService) sendTaskCompletedNotification(task *models.Task, completion *models.TaskCompletion) {
|
|
// Get all users with access to this residence
|
|
users, err := s.residenceRepo.GetResidenceUsers(task.ResidenceID)
|
|
if err != nil {
|
|
log.Error().Err(err).Uint("task_id", task.ID).Msg("Failed to get residence users for notification")
|
|
return
|
|
}
|
|
|
|
// Get residence name
|
|
residence, err := s.residenceRepo.FindByIDSimple(task.ResidenceID)
|
|
residenceName := "your property"
|
|
if err == nil && residence != nil {
|
|
residenceName = residence.Name
|
|
}
|
|
|
|
completedByName := "Someone"
|
|
if completion.CompletedBy.ID > 0 {
|
|
completedByName = completion.CompletedBy.GetFullName()
|
|
}
|
|
|
|
title := "Task Completed"
|
|
body := fmt.Sprintf("%s completed: %s", completedByName, task.Title)
|
|
|
|
data := map[string]interface{}{
|
|
"task_id": task.ID,
|
|
"residence_id": task.ResidenceID,
|
|
"completion_id": completion.ID,
|
|
}
|
|
|
|
// Notify all users
|
|
for _, user := range users {
|
|
isCompleter := user.ID == completion.CompletedByID
|
|
|
|
// Send push notification (to everyone EXCEPT the person who completed it)
|
|
if !isCompleter && s.notificationService != nil {
|
|
go func(userID uint) {
|
|
ctx := context.Background()
|
|
if err := s.notificationService.CreateAndSendNotification(
|
|
ctx,
|
|
userID,
|
|
models.NotificationTaskCompleted,
|
|
title,
|
|
body,
|
|
data,
|
|
); err != nil {
|
|
log.Error().Err(err).Uint("user_id", userID).Uint("task_id", task.ID).Msg("Failed to send task completion push notification")
|
|
}
|
|
}(user.ID)
|
|
}
|
|
|
|
// Send email notification (to everyone INCLUDING the person who completed it)
|
|
if s.emailService != nil && user.Email != "" {
|
|
go func(u models.User) {
|
|
if err := s.emailService.SendTaskCompletedEmail(
|
|
u.Email,
|
|
u.GetFullName(),
|
|
task.Title,
|
|
completedByName,
|
|
residenceName,
|
|
); err != nil {
|
|
log.Error().Err(err).Str("email", u.Email).Uint("task_id", task.ID).Msg("Failed to send task completion email")
|
|
} else {
|
|
log.Info().Str("email", u.Email).Uint("task_id", task.ID).Msg("Task completion email sent")
|
|
}
|
|
}(user)
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetCompletion gets a task completion by ID
|
|
func (s *TaskService) GetCompletion(completionID, userID uint) (*responses.TaskCompletionResponse, error) {
|
|
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrCompletionNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Check access via task's residence
|
|
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !hasAccess {
|
|
return nil, ErrTaskAccessDenied
|
|
}
|
|
|
|
resp := responses.NewTaskCompletionResponse(completion)
|
|
return &resp, nil
|
|
}
|
|
|
|
// ListCompletions lists all task completions for a user
|
|
func (s *TaskService) ListCompletions(userID uint) (*responses.TaskCompletionListResponse, error) {
|
|
// Get all residence IDs
|
|
residences, err := s.residenceRepo.FindByUser(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
residenceIDs := make([]uint, len(residences))
|
|
for i, r := range residences {
|
|
residenceIDs[i] = r.ID
|
|
}
|
|
|
|
if len(residenceIDs) == 0 {
|
|
return &responses.TaskCompletionListResponse{Count: 0, Results: []responses.TaskCompletionResponse{}}, nil
|
|
}
|
|
|
|
completions, err := s.taskRepo.FindCompletionsByUser(userID, residenceIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
resp := responses.NewTaskCompletionListResponse(completions)
|
|
return &resp, nil
|
|
}
|
|
|
|
// DeleteCompletion deletes a task completion
|
|
func (s *TaskService) DeleteCompletion(completionID, userID uint) error {
|
|
completion, err := s.taskRepo.FindCompletionByID(completionID)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrCompletionNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Check access
|
|
hasAccess, err := s.residenceRepo.HasAccess(completion.Task.ResidenceID, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !hasAccess {
|
|
return ErrTaskAccessDenied
|
|
}
|
|
|
|
return s.taskRepo.DeleteCompletion(completionID)
|
|
}
|
|
|
|
// === Lookups ===
|
|
|
|
// GetCategories returns all task categories
|
|
func (s *TaskService) GetCategories() ([]responses.TaskCategoryResponse, error) {
|
|
categories, err := s.taskRepo.GetAllCategories()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]responses.TaskCategoryResponse, len(categories))
|
|
for i, c := range categories {
|
|
result[i] = *responses.NewTaskCategoryResponse(&c)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetPriorities returns all task priorities
|
|
func (s *TaskService) GetPriorities() ([]responses.TaskPriorityResponse, error) {
|
|
priorities, err := s.taskRepo.GetAllPriorities()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]responses.TaskPriorityResponse, len(priorities))
|
|
for i, p := range priorities {
|
|
result[i] = *responses.NewTaskPriorityResponse(&p)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetStatuses returns all task statuses
|
|
func (s *TaskService) GetStatuses() ([]responses.TaskStatusResponse, error) {
|
|
statuses, err := s.taskRepo.GetAllStatuses()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]responses.TaskStatusResponse, len(statuses))
|
|
for i, st := range statuses {
|
|
result[i] = *responses.NewTaskStatusResponse(&st)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetFrequencies returns all task frequencies
|
|
func (s *TaskService) GetFrequencies() ([]responses.TaskFrequencyResponse, error) {
|
|
frequencies, err := s.taskRepo.GetAllFrequencies()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]responses.TaskFrequencyResponse, len(frequencies))
|
|
for i, f := range frequencies {
|
|
result[i] = *responses.NewTaskFrequencyResponse(&f)
|
|
}
|
|
return result, nil
|
|
}
|