package api import ( "encoding/json" "net/http" "strconv" "woke/db" ) // Notifier is called after mutating operations to notify the TUI. type Notifier func() // Server provides a REST API for alarm CRUD. type Server struct { store *db.Store mux *http.ServeMux notify Notifier } func New(store *db.Store, notify Notifier) *Server { s := &Server{store: store, notify: notify} s.mux = http.NewServeMux() s.mux.HandleFunc("GET /api/alarms", s.listAlarms) s.mux.HandleFunc("GET /api/alarms/{id}", s.getAlarm) s.mux.HandleFunc("POST /api/alarms", s.createAlarm) s.mux.HandleFunc("PUT /api/alarms/{id}", s.updateAlarm) s.mux.HandleFunc("DELETE /api/alarms/{id}", s.deleteAlarm) s.mux.HandleFunc("PATCH /api/alarms/{id}/toggle", s.toggleAlarm) return s } func (s *Server) Handler() http.Handler { return s.mux } type alarmRequest struct { Name string `json:"name"` Description string `json:"description"` Time string `json:"time"` Trigger string `json:"trigger"` SoundPath string `json:"sound_path"` Enabled *bool `json:"enabled"` } type alarmResponse struct { ID int `json:"id"` Name string `json:"name"` Description string `json:"description"` Time string `json:"time"` Trigger string `json:"trigger"` SoundPath string `json:"sound_path"` Enabled bool `json:"enabled"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` LastTriggered *string `json:"last_triggered"` SnoozeCount int `json:"snooze_count"` } func toResponse(a db.Alarm) alarmResponse { r := alarmResponse{ ID: a.ID, Name: a.Name, Description: a.Description, Time: a.Time, Trigger: a.Trigger, SoundPath: a.SoundPath, Enabled: a.Enabled, CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"), UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"), SnoozeCount: a.SnoozeCount, } if a.LastTriggered != nil { t := a.LastTriggered.Format("2006-01-02T15:04:05Z") r.LastTriggered = &t } return r } 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 writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } func (s *Server) listAlarms(w http.ResponseWriter, r *http.Request) { alarms, err := s.store.ListAlarms() if err != nil { writeError(w, http.StatusInternalServerError, "failed to list alarms") return } resp := make([]alarmResponse, len(alarms)) for i, a := range alarms { resp[i] = toResponse(a) } writeJSON(w, http.StatusOK, resp) } func (s *Server) getAlarm(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } alarm, err := s.store.GetAlarm(id) if err != nil { writeError(w, http.StatusNotFound, "alarm not found") return } writeJSON(w, http.StatusOK, toResponse(alarm)) } func (s *Server) createAlarm(w http.ResponseWriter, r *http.Request) { var req alarmRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON") return } if req.Name == "" { writeError(w, http.StatusBadRequest, "name is required") return } if req.Trigger == "" { writeError(w, http.StatusBadRequest, "trigger is required") return } alarm := db.Alarm{ Name: req.Name, Description: req.Description, Time: req.Time, Trigger: req.Trigger, SoundPath: req.SoundPath, Enabled: true, } if alarm.SoundPath == "" { alarm.SoundPath = "default" } if req.Enabled != nil { alarm.Enabled = *req.Enabled } id, err := s.store.CreateAlarm(alarm) if err != nil { writeError(w, http.StatusInternalServerError, "failed to create alarm") return } created, err := s.store.GetAlarm(id) if err != nil { writeError(w, http.StatusInternalServerError, "failed to read created alarm") return } s.notify() writeJSON(w, http.StatusCreated, toResponse(created)) } func (s *Server) updateAlarm(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } existing, err := s.store.GetAlarm(id) if err != nil { writeError(w, http.StatusNotFound, "alarm not found") return } var req alarmRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON") return } if req.Name != "" { existing.Name = req.Name } if req.Description != "" { existing.Description = req.Description } if req.Time != "" { existing.Time = req.Time } if req.Trigger != "" { existing.Trigger = req.Trigger } if req.SoundPath != "" { existing.SoundPath = req.SoundPath } if req.Enabled != nil { existing.Enabled = *req.Enabled } if err := s.store.UpdateAlarm(existing); err != nil { writeError(w, http.StatusInternalServerError, "failed to update alarm") return } updated, _ := s.store.GetAlarm(id) s.notify() writeJSON(w, http.StatusOK, toResponse(updated)) } func (s *Server) deleteAlarm(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } if _, err := s.store.GetAlarm(id); err != nil { writeError(w, http.StatusNotFound, "alarm not found") return } if err := s.store.DeleteAlarm(id); err != nil { writeError(w, http.StatusInternalServerError, "failed to delete alarm") return } s.notify() w.WriteHeader(http.StatusNoContent) } func (s *Server) toggleAlarm(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) if err != nil { writeError(w, http.StatusBadRequest, "invalid id") return } if _, err := s.store.GetAlarm(id); err != nil { writeError(w, http.StatusNotFound, "alarm not found") return } if err := s.store.ToggleAlarm(id); err != nil { writeError(w, http.StatusInternalServerError, "failed to toggle alarm") return } toggled, _ := s.store.GetAlarm(id) s.notify() writeJSON(w, http.StatusOK, toResponse(toggled)) }