Files
Gardomatic/internal/api/tasks.go
T
kleiax 904d14b64c
CI / test (push) Canceled after 0s
Initial commit
2026-09-12 22:22:17 +02:00

372 lines
12 KiB
Go

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
}