372 lines
12 KiB
Go
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
|
|
}
|