Files
gems/opal-task/internal/api/handlers/tasks.go
T
joakim ba0cfc08e3 feat: Phase 1 - Core API server with authentication
- Added database schema for users, api_keys, sync_state, change_log, and sync_config
- Implemented API key generation and validation with bcrypt hashing
- Created Chi-based REST API server with endpoints for:
  - Task CRUD operations (create, read, update, delete)
  - Task actions (complete, start, stop)
  - Tag management (list, add, remove)
  - Projects listing
  - Health check endpoint
- Added middleware for authentication and CORS
- Implemented change log tracking with triggers (key:value format)
- Added configurable change log retention (default 30 days)
- Created server CLI commands (opal server start, opal server keygen)
- Dependencies added: golang.org/x/crypto/bcrypt, github.com/go-chi/chi/v5
2026-01-05 16:14:49 +01:00

436 lines
10 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"net/http"
"time"
"git.jnss.me/joakim/opal/internal/engine"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
// Response helpers (avoiding import cycle)
func jsonResponse(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": status >= 200 && status < 300,
"data": data,
})
}
func errorResponse(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": message,
})
}
// ListTasks returns tasks based on filter query parameters
func ListTasks(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
// Build filter from query params
filter := engine.NewFilter()
// Status filter
if status := query.Get("status"); status != "" {
filter.Attributes["status"] = status
}
// Project filter
if project := query.Get("project"); project != "" {
filter.Attributes["project"] = project
}
// Priority filter
if priority := query.Get("priority"); priority != "" {
filter.Attributes["priority"] = priority
}
// Tag filters
if tags := query["tag"]; len(tags) > 0 {
filter.IncludeTags = tags
}
// Get tasks
tasks, err := engine.GetTasks(filter)
if err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, tasks)
}
// CreateTaskRequest represents the request body for creating a task
type CreateTaskRequest struct {
Description string `json:"description"`
Tags []string `json:"tags,omitempty"`
Project *string `json:"project,omitempty"`
Priority *string `json:"priority,omitempty"`
Due *int64 `json:"due,omitempty"`
Scheduled *int64 `json:"scheduled,omitempty"`
Wait *int64 `json:"wait,omitempty"`
Until *int64 `json:"until,omitempty"`
Recurrence *string `json:"recurrence,omitempty"`
}
// CreateTask creates a new task
func CreateTask(w http.ResponseWriter, r *http.Request) {
var req CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Description == "" {
errorResponse(w, http.StatusBadRequest, "description is required")
return
}
// Build modifier from request
mod := engine.NewModifier()
mod.AddTags = req.Tags
if req.Project != nil {
mod.SetAttributes["project"] = req.Project
}
if req.Priority != nil {
mod.SetAttributes["priority"] = req.Priority
}
if req.Due != nil {
dueStr := fmt.Sprintf("%d", *req.Due)
mod.SetAttributes["due"] = &dueStr
}
if req.Scheduled != nil {
scheduledStr := fmt.Sprintf("%d", *req.Scheduled)
mod.SetAttributes["scheduled"] = &scheduledStr
}
if req.Wait != nil {
waitStr := fmt.Sprintf("%d", *req.Wait)
mod.SetAttributes["wait"] = &waitStr
}
if req.Until != nil {
untilStr := fmt.Sprintf("%d", *req.Until)
mod.SetAttributes["until"] = &untilStr
}
if req.Recurrence != nil {
mod.SetAttributes["recurrence"] = req.Recurrence
}
// Create task
task, err := engine.CreateTaskWithModifier(req.Description, mod)
if err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusCreated, task)
}
// GetTask returns a single task by UUID
func GetTask(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
jsonResponse(w, http.StatusOK, task)
}
// UpdateTaskRequest represents the request body for updating a task
type UpdateTaskRequest struct {
Description *string `json:"description,omitempty"`
Status *string `json:"status,omitempty"`
Priority *string `json:"priority,omitempty"`
Project *string `json:"project,omitempty"`
Due *int64 `json:"due,omitempty"`
Scheduled *int64 `json:"scheduled,omitempty"`
Wait *int64 `json:"wait,omitempty"`
Until *int64 `json:"until,omitempty"`
Start *int64 `json:"start,omitempty"`
Recurrence *string `json:"recurrence,omitempty"`
}
// UpdateTask updates an existing task
func UpdateTask(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
var req UpdateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
// Build modifier
mod := engine.NewModifier()
if req.Description != nil {
mod.SetAttributes["description"] = req.Description
}
if req.Status != nil {
mod.SetAttributes["status"] = req.Status
}
if req.Priority != nil {
mod.SetAttributes["priority"] = req.Priority
}
if req.Project != nil {
mod.SetAttributes["project"] = req.Project
}
if req.Due != nil {
dueStr := fmt.Sprintf("%d", *req.Due)
mod.SetAttributes["due"] = &dueStr
}
if req.Scheduled != nil {
scheduledStr := fmt.Sprintf("%d", *req.Scheduled)
mod.SetAttributes["scheduled"] = &scheduledStr
}
if req.Wait != nil {
waitStr := fmt.Sprintf("%d", *req.Wait)
mod.SetAttributes["wait"] = &waitStr
}
if req.Until != nil {
untilStr := fmt.Sprintf("%d", *req.Until)
mod.SetAttributes["until"] = &untilStr
}
if req.Start != nil {
t := time.Unix(*req.Start, 0)
task.Start = &t
}
if req.Recurrence != nil {
mod.SetAttributes["recurrence"] = req.Recurrence
}
// Apply modifier
if err := mod.Apply(task); err != nil {
errorResponse(w, http.StatusBadRequest, err.Error())
return
}
jsonResponse(w, http.StatusOK, task)
}
// DeleteTask deletes a task
func DeleteTask(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
// Check for permanent delete flag
permanent := r.URL.Query().Get("permanent") == "true"
if err := task.Delete(permanent); err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, map[string]string{"message": "task deleted"})
}
// CompleteTask marks a task as completed
func CompleteTask(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
if err := task.Complete(); err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, task)
}
// StartTask sets the start time for a task
func StartTask(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
if err := task.StartTask(); err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, task)
}
// StopTask clears the start time for a task
func StopTask(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
if err := task.StopTask(); err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, task)
}
// GetTaskTags returns all tags for a task
func GetTaskTags(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
jsonResponse(w, http.StatusOK, task.Tags)
}
// AddTaskTagRequest represents the request to add a tag
type AddTaskTagRequest struct {
Tag string `json:"tag"`
}
// AddTaskTag adds a tag to a task
func AddTaskTag(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
var req AddTaskTagRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Tag == "" {
errorResponse(w, http.StatusBadRequest, "tag is required")
return
}
if err := task.AddTag(req.Tag); err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, task)
}
// RemoveTaskTag removes a tag from a task
func RemoveTaskTag(w http.ResponseWriter, r *http.Request) {
uuidStr := chi.URLParam(r, "uuid")
tag := chi.URLParam(r, "tag")
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
errorResponse(w, http.StatusBadRequest, "invalid UUID")
return
}
task, err := engine.GetTask(taskUUID)
if err != nil {
errorResponse(w, http.StatusNotFound, "task not found")
return
}
if err := task.RemoveTag(tag); err != nil {
errorResponse(w, http.StatusInternalServerError, err.Error())
return
}
jsonResponse(w, http.StatusOK, task)
}