package server import ( "encoding/json" "errors" "net/http" "strconv" "strings" "tracker/internal/store" ) func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func apiError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } func readJSON(w http.ResponseWriter, r *http.Request, dst any) bool { r.Body = http.MaxBytesReader(w, r.Body, 1<<20) dec := json.NewDecoder(r.Body) if err := dec.Decode(dst); err != nil { apiError(w, http.StatusBadRequest, "invalid json body: "+err.Error()) return false } return true } func (s *Server) apiProjectID(w http.ResponseWriter, r *http.Request) (int64, bool) { return s.apiID(w, r) } func (s *Server) apiID(w http.ResponseWriter, r *http.Request) (int64, bool) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || id < 1 { apiError(w, http.StatusBadRequest, "invalid id") return 0, false } return id, true } func (s *Server) apiNotFound(w http.ResponseWriter, err error) bool { if errors.Is(err, store.ErrNotFound) { apiError(w, http.StatusNotFound, "not found") return true } return false } // --- projects --- type projectPayload struct { Name *string `json:"name"` Description *string `json:"description"` } func (s *Server) handleAPIProjectList(w http.ResponseWriter, r *http.Request) { projects, err := s.store.Projects() if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } if projects == nil { projects = []store.Project{} } writeJSON(w, http.StatusOK, projects) } func (s *Server) handleAPIProjectCreate(w http.ResponseWriter, r *http.Request) { var p projectPayload if !readJSON(w, r, &p) { return } if p.Name == nil || strings.TrimSpace(*p.Name) == "" { apiError(w, http.StatusBadRequest, "name is required") return } desc := "" if p.Description != nil { desc = strings.TrimSpace(*p.Description) } id, err := s.store.CreateProject(strings.TrimSpace(*p.Name), desc) if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } project, err := s.store.Project(id) if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusCreated, project) } func (s *Server) handleAPIProjectGet(w http.ResponseWriter, r *http.Request) { id, ok := s.apiProjectID(w, r) if !ok { return } project, err := s.store.Project(id) if s.apiNotFound(w, err) { return } if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, project) } func (s *Server) handleAPIProjectUpdate(w http.ResponseWriter, r *http.Request) { id, ok := s.apiProjectID(w, r) if !ok { return } var p projectPayload if !readJSON(w, r, &p) { return } project, err := s.store.Project(id) if s.apiNotFound(w, err) { return } if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } if p.Name != nil { if strings.TrimSpace(*p.Name) == "" { apiError(w, http.StatusBadRequest, "name cannot be empty") return } project.Name = strings.TrimSpace(*p.Name) } if p.Description != nil { project.Description = strings.TrimSpace(*p.Description) } if err := s.store.UpdateProject(id, project.Name, project.Description); err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, project) } func (s *Server) handleAPIProjectDelete(w http.ResponseWriter, r *http.Request) { id, ok := s.apiProjectID(w, r) if !ok { return } if err := s.store.DeleteProject(id); s.apiNotFound(w, err) { return } else if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // --- notes --- type notePayload struct { Body *string `json:"body"` } func (s *Server) handleAPINoteList(w http.ResponseWriter, r *http.Request) { id, ok := s.apiProjectID(w, r) if !ok { return } notes, err := s.store.Notes(id) if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } if notes == nil { notes = []store.Note{} } writeJSON(w, http.StatusOK, notes) } func (s *Server) handleAPINoteCreate(w http.ResponseWriter, r *http.Request) { id, ok := s.apiProjectID(w, r) if !ok { return } var n notePayload if !readJSON(w, r, &n) { return } if n.Body == nil || strings.TrimSpace(*n.Body) == "" { apiError(w, http.StatusBadRequest, "body is required") return } if _, err := s.store.Project(id); s.apiNotFound(w, err) { return } else if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } noteID, err := s.store.CreateNote(id, strings.TrimSpace(*n.Body)) if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } notes, err := s.store.Notes(id) if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } for _, note := range notes { if note.ID == noteID { writeJSON(w, http.StatusCreated, note) return } } writeJSON(w, http.StatusCreated, map[string]int64{"id": noteID}) } func (s *Server) handleAPINoteGet(w http.ResponseWriter, r *http.Request) { id, ok := s.apiID(w, r) if !ok { return } note, err := s.store.Note(id) if s.apiNotFound(w, err) { return } if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, note) } func (s *Server) handleAPINoteUpdate(w http.ResponseWriter, r *http.Request) { id, ok := s.apiID(w, r) if !ok { return } var n notePayload if !readJSON(w, r, &n) { return } if n.Body == nil || strings.TrimSpace(*n.Body) == "" { apiError(w, http.StatusBadRequest, "body is required") return } if err := s.store.UpdateNote(id, strings.TrimSpace(*n.Body)); s.apiNotFound(w, err) { return } else if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) } func (s *Server) handleAPINoteDelete(w http.ResponseWriter, r *http.Request) { id, ok := s.apiID(w, r) if !ok { return } if err := s.store.DeleteNote(id); s.apiNotFound(w, err) { return } else if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // --- tasks --- type taskPayload struct { Title *string `json:"title"` Status *string `json:"status"` } var validStatuses = map[string]bool{"todo": true, "doing": true, "done": true} func (s *Server) handleAPITaskList(w http.ResponseWriter, r *http.Request) { id, ok := s.apiProjectID(w, r) if !ok { return } tasks, err := s.store.Tasks(id) if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } if tasks == nil { tasks = []store.Task{} } writeJSON(w, http.StatusOK, tasks) } func (s *Server) handleAPITaskCreate(w http.ResponseWriter, r *http.Request) { id, ok := s.apiProjectID(w, r) if !ok { return } var t taskPayload if !readJSON(w, r, &t) { return } if t.Title == nil || strings.TrimSpace(*t.Title) == "" { apiError(w, http.StatusBadRequest, "title is required") return } if t.Status != nil && !validStatuses[*t.Status] { apiError(w, http.StatusBadRequest, "status must be todo, doing, or done") return } if _, err := s.store.Project(id); s.apiNotFound(w, err) { return } else if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } taskID, err := s.store.CreateTask(id, strings.TrimSpace(*t.Title)) if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } if t.Status != nil && *t.Status != "todo" { if err := s.store.SetTaskStatus(taskID, *t.Status); err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } } tasks, err := s.store.Tasks(id) if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } for _, task := range tasks { if task.ID == taskID { writeJSON(w, http.StatusCreated, task) return } } writeJSON(w, http.StatusCreated, map[string]int64{"id": taskID}) } func (s *Server) handleAPITaskGet(w http.ResponseWriter, r *http.Request) { id, ok := s.apiID(w, r) if !ok { return } task, err := s.store.Task(id) if s.apiNotFound(w, err) { return } if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, task) } func (s *Server) handleAPITaskUpdate(w http.ResponseWriter, r *http.Request) { id, ok := s.apiID(w, r) if !ok { return } var t taskPayload if !readJSON(w, r, &t) { return } if t.Status != nil && !validStatuses[*t.Status] { apiError(w, http.StatusBadRequest, "status must be todo, doing, or done") return } task, err := s.store.Task(id) if s.apiNotFound(w, err) { return } if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } if t.Title != nil { if strings.TrimSpace(*t.Title) == "" { apiError(w, http.StatusBadRequest, "title cannot be empty") return } task.Title = strings.TrimSpace(*t.Title) if err := s.store.UpdateTaskTitle(id, task.Title); err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } } if t.Status != nil { task.Status = *t.Status if err := s.store.SetTaskStatus(id, task.Status); err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } } writeJSON(w, http.StatusOK, task) } func (s *Server) handleAPITaskDelete(w http.ResponseWriter, r *http.Request) { id, ok := s.apiID(w, r) if !ok { return } if err := s.store.DeleteTask(id); s.apiNotFound(w, err) { return } else if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // --- search --- func (s *Server) handleAPISearch(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) if q == "" { apiError(w, http.StatusBadRequest, "q parameter is required") return } hits, err := s.store.Search(q) if err != nil { apiError(w, http.StatusInternalServerError, err.Error()) return } if hits == nil { hits = []store.Hit{} } writeJSON(w, http.StatusOK, hits) }