@@ -0,0 +1,371 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user