package api import ( "database/sql" "encoding/csv" "encoding/json" "errors" "fmt" "net/http" "strconv" "time" "gpsbackend/internal/model" "gpsbackend/internal/store" ) const ( defaultTrackLimit = 500 maxTrackLimit = 5000 ) // handleListDevices returns every known device with status and last position. func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { devices, err := s.store.ListDevices(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "could not list devices") return } def, _ := s.store.DefaultInterval(r.Context()) now := time.Now().UnixMilli() out := make([]model.DeviceDTO, 0, len(devices)) for _, d := range devices { out = append(out, toDeviceDTO(d, def, now)) } writeJSON(w, http.StatusOK, out) } // handleGetDevice returns a single device. func (s *Server) handleGetDevice(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") d, err := s.store.GetDevice(r.Context(), id) if errors.Is(err, sql.ErrNoRows) { writeError(w, http.StatusNotFound, "device not found") return } if err != nil { writeError(w, http.StatusInternalServerError, "could not load device") return } def, _ := s.store.DefaultInterval(r.Context()) writeJSON(w, http.StatusOK, toDeviceDTO(d, def, time.Now().UnixMilli())) } // handleUpdateDevice sets a device's name and per-device capture interval. func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var req model.DeviceUpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON") return } if req.CaptureIntervalSec != nil && *req.CaptureIntervalSec < 10 { writeError(w, http.StatusBadRequest, "capture_interval_sec must be >= 10") return } if err := s.store.SetDeviceConfig(r.Context(), id, req.Name, req.CaptureIntervalSec); err != nil { writeError(w, http.StatusInternalServerError, "could not update device") return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } // handleGetTrack returns a device's location history. func (s *Server) handleGetTrack(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") since, limit, ok := trackParams(w, r) if !ok { return } locs, err := s.store.Track(r.Context(), id, since, limit) if err != nil { writeError(w, http.StatusInternalServerError, "could not load track") return } points := make([]model.TrackPoint, 0, len(locs)) for _, l := range locs { points = append(points, model.TrackPoint{ Lat: l.Lat, Lng: l.Lng, Accuracy: l.Accuracy, Speed: l.Speed, Bearing: l.Bearing, Battery: l.Battery, Timestamp: model.MillisToRFC3339(l.Timestamp), }) } writeJSON(w, http.StatusOK, model.TrackResponse{DeviceID: id, Points: points}) } // handleExportCSV streams a device's track as a CSV download (Reports feature). func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") locs, err := s.store.Track(r.Context(), id, 0, maxTrackLimit) if err != nil { writeError(w, http.StatusInternalServerError, "could not load track") return } w.Header().Set("Content-Type", "text/csv") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="track-%s.csv"`, id)) cw := csv.NewWriter(w) _ = cw.Write([]string{"timestamp", "lat", "lng", "accuracy_m", "speed_mps", "bearing_deg", "battery_pct"}) for _, l := range locs { _ = cw.Write([]string{ model.MillisToRFC3339(l.Timestamp), strconv.FormatFloat(l.Lat, 'f', 6, 64), strconv.FormatFloat(l.Lng, 'f', 6, 64), floatOrEmpty(l.Accuracy), floatOrEmpty(l.Speed), floatOrEmpty(l.Bearing), intOrEmpty(l.Battery), }) } cw.Flush() } func trackParams(w http.ResponseWriter, r *http.Request) (since int64, limit int, ok bool) { limit = defaultTrackLimit if raw := r.URL.Query().Get("since"); raw != "" { ms, valid := model.ParseTimestamp(raw) if !valid { writeError(w, http.StatusBadRequest, "invalid 'since' timestamp (expected RFC3339)") return 0, 0, false } since = ms } if raw := r.URL.Query().Get("limit"); raw != "" { n, err := strconv.Atoi(raw) if err != nil || n <= 0 { writeError(w, http.StatusBadRequest, "invalid 'limit'") return 0, 0, false } limit = min(n, maxTrackLimit) } return since, limit, true } // toDeviceDTO converts a store row into its JSON representation, computing // the device's status and effective capture interval. func toDeviceDTO(d store.DeviceWithLocation, defaultIntervalSec int, nowMs int64) model.DeviceDTO { effective := defaultIntervalSec if d.CaptureIntervalSec != nil && *d.CaptureIntervalSec > 0 { effective = *d.CaptureIntervalSec } if effective <= 0 { effective = 120 } dto := model.DeviceDTO{ ID: d.ID, Name: d.Name, Status: deviceStatus(d, effective, nowMs), Battery: d.Battery, CaptureIntervalSec: d.CaptureIntervalSec, EffectiveIntervalSec: effective, FirstSeenAt: model.MillisToRFC3339(d.FirstSeenAt), LastSeenAt: model.MillisToRFC3339(d.LastSeenAt), } if d.Last != nil { dto.LastLocation = &model.LocationDTO{ Lat: d.Last.Lat, Lng: d.Last.Lng, Accuracy: d.Last.Accuracy, Speed: d.Last.Speed, Bearing: d.Last.Bearing, Timestamp: model.MillisToRFC3339(d.Last.Timestamp), } } return dto } // deviceStatus classifies a device as online / offline / inactive. func deviceStatus(d store.DeviceWithLocation, effectiveSec int, nowMs int64) string { if d.Last == nil { return "inactive" } age := nowMs - d.LastSeenAt onlineWindow := int64(effectiveSec) * 3 * 1000 if onlineWindow < 6*60*1000 { onlineWindow = 6 * 60 * 1000 // floor: 6 minutes } switch { case age <= onlineWindow: return "online" case age >= 24*60*60*1000: return "inactive" default: return "offline" } } func floatOrEmpty(v *float64) string { if v == nil { return "" } return strconv.FormatFloat(*v, 'f', 2, 64) } func intOrEmpty(v *int) string { if v == nil { return "" } return strconv.Itoa(*v) }