package api import ( "errors" "fmt" "net/http" "strings" "time" "gardomatic.kleiax.de/internal/platform/validate" "gardomatic.kleiax.de/internal/storage" ) type taskInput struct { Tags []string `json:"tags"` PlantID *int `json:"plant_id"` LocationID *int `json:"location_id"` ClearPlantID bool `json:"clear_plant_id"` ClearLocationID bool `json:"clear_location_id"` Title *string `json:"title"` Description *string `json:"description"` DueAtStart *time.Time `json:"due_at_start"` DueAtEnd *time.Time `json:"due_at_end"` ClearDueAtStart bool `json:"clear_due_at_start"` ClearDueAtEnd bool `json:"clear_due_at_end"` Recurrence *storage.TaskRecurrence `json:"recurrence"` RecurrenceInterval *int `json:"recurrence_interval"` Priority *int `json:"priority"` Active *bool `json:"active"` Completed *bool `json:"completed"` PlantStatusOnCompletion *string `json:"plant_status_on_completion"` } func (input taskInput) isCompletionOnly() bool { return input.Completed != nil && input.Tags == nil && input.PlantID == nil && input.LocationID == nil && !input.ClearPlantID && !input.ClearLocationID && input.Title == nil && input.Description == nil && input.DueAtStart == nil && input.DueAtEnd == nil && !input.ClearDueAtStart && !input.ClearDueAtEnd && input.Recurrence == nil && input.RecurrenceInterval == nil && input.Priority == nil && input.Active == nil && input.PlantStatusOnCompletion == nil } func (input taskInput) apply(task *storage.Task, userID int) { if input.PlantID != nil { task.PlantID = input.PlantID } if input.LocationID != nil { task.LocationID = input.LocationID } if input.ClearPlantID { task.PlantID = nil } if input.ClearLocationID { task.LocationID = nil } if input.Title != nil { task.Title = strings.TrimSpace(*input.Title) } if input.Description != nil { task.Description = strings.TrimSpace(*input.Description) } if input.DueAtStart != nil { task.DueAtStart = input.DueAtStart } if input.DueAtEnd != nil { task.DueAtEnd = input.DueAtEnd } if input.ClearDueAtStart { task.DueAtStart = nil } if input.ClearDueAtEnd { task.DueAtEnd = nil } if input.Recurrence != nil { task.Recurrence = *input.Recurrence } if input.RecurrenceInterval != nil { task.RecurrenceInterval = *input.RecurrenceInterval } if input.Priority != nil { task.Priority = *input.Priority } if input.Active != nil { task.Active = *input.Active } if input.Completed != nil { if *input.Completed { now := time.Now().UTC() task.CompletedAt, task.CompletedBy = &now, &userID } else { task.CompletedAt, task.CompletedBy = nil, nil } } assignStringPointer(input.PlantStatusOnCompletion, &task.PlantStatusOnCompletion) } func (app *application) createTaskHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) user, _ := app.contextGetAuthenticatedUser(r) var input taskInput if err := app.readJSON(w, r, &input); err != nil { app.badRequestResponse(w, r, err) return } task := storage.Task{GardenID: gardenID, CreatedBy: user.ID, Active: true, RecurrenceInterval: 1} input.apply(&task, user.ID) if task.RecurrenceInterval < 1 { task.RecurrenceInterval = 1 } task.Tags = storage.NormalizeTags(input.Tags) if !app.validateTaskForGarden(w, r, gardenID, task) { return } task, err := app.models.Tasks.Insert(task) if err != nil { app.respondToEntityModelError(w, r, err) return } if task.Tags, err = app.saveTags(gardenID, storage.TagEntityTask, task.ID, task.Tags); err != nil { app.serverErrorResponse(w, r, err) return } headers := make(http.Header) headers.Set("Location", fmt.Sprintf("/v1/gardens/%d/tasks/%d", gardenID, task.ID)) if err := app.writeJSON(w, http.StatusCreated, envelope{"task": task}, headers); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) listTasksHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) user, _ := app.contextGetAuthenticatedUser(r) if err := app.generateTasks(gardenID, user.ID, time.Now()); err != nil { app.serverErrorResponse(w, r, err) return } tasks, err := app.models.Tasks.GetAllForGarden(gardenID) if err != nil { app.serverErrorResponse(w, r, err) return } member, _ := app.contextGetGardenMember(r) visible := tasks[:0] for i := range tasks { if tasks[i].CreatedBy != user.ID && !member.Can(storage.GardenPermissionTaskReadOther) { continue } if tasks[i].CreatedBy == user.ID && !member.Can(storage.GardenPermissionTaskReadOwn) { continue } tasks[i].Tags, err = app.loadTags(gardenID, storage.TagEntityTask, tasks[i].ID) if err != nil { app.serverErrorResponse(w, r, err) return } visible = append(visible, tasks[i]) } tasks = visible if err := app.writeJSON(w, http.StatusOK, envelope{"tasks": tasks}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) showTaskHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) id, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } task, err := app.models.Tasks.Get(gardenID, id) if err != nil { app.respondToEntityModelError(w, r, err) return } if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskReadOwn, storage.GardenPermissionTaskReadOther) { return } task.Tags, err = app.loadTags(gardenID, storage.TagEntityTask, task.ID) if err != nil { app.serverErrorResponse(w, r, err) return } if err := app.writeJSON(w, http.StatusOK, envelope{"task": task}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) updateTaskHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) id, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } task, err := app.models.Tasks.Get(gardenID, id) if err != nil { app.respondToEntityModelError(w, r, err) return } var input taskInput if err := app.readJSON(w, r, &input); err != nil { app.badRequestResponse(w, r, err) return } if input.isCompletionOnly() { if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskCompleteOwn, storage.GardenPermissionTaskCompleteOther) { return } } else if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskUpdateOwn, storage.GardenPermissionTaskUpdateOther) { return } user, _ := app.contextGetAuthenticatedUser(r) input.apply(&task, user.ID) if task.RecurrenceInterval < 1 { task.RecurrenceInterval = 1 } if input.Tags != nil { task.Tags = storage.NormalizeTags(input.Tags) } if !app.validateTaskForGarden(w, r, gardenID, task) { return } task, err = app.models.Tasks.Update(gardenID, task) if err != nil { app.respondToEntityModelError(w, r, err) return } if input.Completed != nil && *input.Completed && task.PlantID != nil && task.PlantStatusOnCompletion != nil { plant, plantErr := app.models.Plants.Get(gardenID, *task.PlantID) if plantErr != nil { app.respondToEntityModelError(w, r, plantErr) return } plant.Status, plant.UpdatedBy = *task.PlantStatusOnCompletion, user.ID if _, plantErr = app.models.Plants.Update(gardenID, plant); plantErr != nil { app.respondToEntityModelError(w, r, plantErr) return } } if input.Tags != nil { task.Tags, err = app.saveTags(gardenID, storage.TagEntityTask, task.ID, task.Tags) if err != nil { app.serverErrorResponse(w, r, err) return } } if input.Completed != nil && *input.Completed && task.Recurrence != storage.TaskRecurrenceNone { if err := app.ensureNextRecurringTask(gardenID, user.ID, task); err != nil { app.serverErrorResponse(w, r, err) return } } if err := app.writeJSON(w, http.StatusOK, envelope{"task": task}, nil); err != nil { app.serverErrorResponse(w, r, err) } } func (app *application) ensureNextRecurringTask(gardenID, userID int, task storage.Task) error { next := task next.ID, next.Version = 0, 0 next.GeneratedFor = nil next.CompletedAt, next.CompletedBy = nil, nil next.CreatedBy = userID next.CreatedAt, next.UpdatedAt = time.Time{}, time.Time{} next.RepeatFromID = &task.ID next.DueAtStart = advanceRecurringTime(task.DueAtStart, task.Recurrence, task.RecurrenceInterval) next.DueAtEnd = advanceRecurringTime(task.DueAtEnd, task.Recurrence, task.RecurrenceInterval) if next.TemplateID != nil && next.DueAtStart != nil { generatedFor := time.Date(next.DueAtStart.Year(), next.DueAtStart.Month(), next.DueAtStart.Day(), 0, 0, 0, 0, next.DueAtStart.Location()) next.GeneratedFor = &generatedFor } created, err := app.models.Tasks.Insert(next) if errors.Is(err, storage.ErrConflict) { return nil } if err != nil { return err } tags, err := app.loadTags(gardenID, storage.TagEntityTask, task.ID) if err != nil { return err } _, err = app.saveTags(gardenID, storage.TagEntityTask, created.ID, tags) return err } func advanceRecurringTime(value *time.Time, recurrence storage.TaskRecurrence, interval int) *time.Time { if value == nil { return nil } result := *value if interval < 1 { interval = 1 } switch recurrence { case storage.TaskRecurrenceDaily: result = result.AddDate(0, 0, interval) case storage.TaskRecurrenceWeekly: result = result.AddDate(0, 0, 7*interval) case storage.TaskRecurrenceMonthly: result = addClampedDate(result, 0, interval) case storage.TaskRecurrenceYearly: result = addClampedDate(result, interval, 0) } return &result } func addClampedDate(value time.Time, years, months int) time.Time { targetMonth := int(value.Month()) + months targetYear := value.Year() + years + (targetMonth-1)/12 targetMonth = (targetMonth-1)%12 + 1 lastDay := time.Date(targetYear, time.Month(targetMonth)+1, 0, 0, 0, 0, 0, value.Location()).Day() day := value.Day() if day > lastDay { day = lastDay } return time.Date(targetYear, time.Month(targetMonth), day, value.Hour(), value.Minute(), value.Second(), value.Nanosecond(), value.Location()) } func (app *application) deleteTaskHandler(w http.ResponseWriter, r *http.Request) { gardenID, _ := app.readGardenIDParam(r) id, err := app.readIDParam(r) if err != nil { app.notFoundResponse(w, r) return } task, err := app.models.Tasks.Get(gardenID, id) if err != nil { app.respondToEntityModelError(w, r, err) return } if !app.authorizeGardenResource(w, r, task.CreatedBy, storage.GardenPermissionTaskDeleteOwn, storage.GardenPermissionTaskDeleteOther) { return } if err := app.models.Tasks.Delete(gardenID, id); err != nil { app.respondToEntityModelError(w, r, err) return } w.WriteHeader(http.StatusNoContent) } func (app *application) validateTaskForGarden(w http.ResponseWriter, r *http.Request, gardenID int, task storage.Task) bool { v := validate.New() storage.ValidateTask(v, task) storage.ValidateTags(v, task.Tags) if !app.validateConfiguredPriority(w, r, v, task.Priority) { return false } if task.PlantID != nil && *task.PlantID > 0 { if _, err := app.models.Plants.Get(gardenID, *task.PlantID); err != nil { if errors.Is(err, storage.ErrRecordNotFound) { v.AddError("plant_id", "must refer to a plant in this garden") } else { app.serverErrorResponse(w, r, err) return false } } } if task.LocationID != nil && *task.LocationID > 0 { if _, err := app.models.Locations.Get(gardenID, *task.LocationID); err != nil { if errors.Is(err, storage.ErrRecordNotFound) { v.AddError("location_id", "must refer to a location in this garden") } else { app.serverErrorResponse(w, r, err) return false } } } if !v.Valid() { app.failedValidationResponse(w, r, v.Errors) return false } return true }