package api import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "time" "gpsbackend/internal/model" "gpsbackend/internal/store" ) const maxBodyBytes = 1 << 20 // handlePostLocations accepts a single LocationReport or a JSON array of them. // The response carries the device's effective capture interval so the app can // adjust how often it reports (dashboard-controlled). func (s *Server) handlePostLocations(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes)) if err != nil { writeError(w, http.StatusBadRequest, "could not read request body") return } reports, err := decodeReports(body) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if len(reports) == 0 { writeError(w, http.StatusBadRequest, "no locations in request") return } accepted := 0 for _, rep := range reports { if err := validateReport(rep); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if err := s.ingestLocation(r.Context(), rep); err != nil { slog.Error("ingest location failed", "device", rep.DeviceID, "err", err) writeError(w, http.StatusInternalServerError, "could not store location") return } accepted++ } interval := s.effectiveIntervalSec(r.Context(), reports[0].DeviceID) writeJSON(w, http.StatusOK, model.LocationAck{ Status: "ok", Accepted: accepted, ReceivedAt: time.Now().UTC().Format(time.RFC3339Nano), CaptureIntervalSec: interval, }) } func decodeReports(body []byte) ([]model.LocationReport, error) { trimmed := bytes.TrimSpace(body) if len(trimmed) == 0 { return nil, fmt.Errorf("empty request body") } if trimmed[0] == '[' { var arr []model.LocationReport if err := json.Unmarshal(trimmed, &arr); err != nil { return nil, fmt.Errorf("invalid JSON array: %v", err) } return arr, nil } var single model.LocationReport if err := json.Unmarshal(trimmed, &single); err != nil { return nil, fmt.Errorf("invalid JSON object: %v", err) } return []model.LocationReport{single}, nil } func validateReport(rep model.LocationReport) error { if rep.DeviceID == "" { return fmt.Errorf("device_id is required") } if rep.Lat < -90 || rep.Lat > 90 { return fmt.Errorf("lat out of range [-90, 90]") } if rep.Lng < -180 || rep.Lng > 180 { return fmt.Errorf("lng out of range [-180, 180]") } if rep.Bearing != nil && (*rep.Bearing < 0 || *rep.Bearing > 360) { return fmt.Errorf("bearing out of range [0, 360]") } if rep.Battery != nil && (*rep.Battery < 0 || *rep.Battery > 100) { return fmt.Errorf("battery out of range [0, 100]") } return nil } // ingestLocation persists a fix, fans it out to dashboards, and raises a // low-battery alert when appropriate. func (s *Server) ingestLocation(ctx context.Context, rep model.LocationReport) error { receivedAt := time.Now().UTC().UnixMilli() tsMillis := receivedAt if ms, ok := model.ParseTimestamp(rep.Timestamp); ok { tsMillis = ms } if err := s.store.InsertLocation(ctx, store.Location{ DeviceID: rep.DeviceID, Lat: rep.Lat, Lng: rep.Lng, Accuracy: rep.Accuracy, Speed: rep.Speed, Bearing: rep.Bearing, Battery: rep.Battery, Timestamp: tsMillis, ReceivedAt: receivedAt, }); err != nil { return err } // One visible log line per accepted POST — makes it instantly obvious in // the Hugging Face Space "Logs" tab which device ids are actually reaching // the server (helps diagnose "I connected two phones but only one shows"). slog.Info("location ingested", "device", rep.DeviceID, "lat", rep.Lat, "lng", rep.Lng, "battery", batteryLog(rep.Battery), ) s.maybeLowBatteryAlert(ctx, rep, receivedAt) msg := model.BroadcastMessage{ Type: "location", DeviceID: rep.DeviceID, Lat: rep.Lat, Lng: rep.Lng, Accuracy: rep.Accuracy, Speed: rep.Speed, Bearing: rep.Bearing, Battery: rep.Battery, Timestamp: model.MillisToRFC3339(tsMillis), ReceivedAt: model.MillisToRFC3339(receivedAt), } if data, err := json.Marshal(msg); err == nil { s.hub.Broadcast(data) } return nil } // maybeLowBatteryAlert raises a Low Battery alert when battery < 20%, deduped // to at most one unacknowledged alert per device per 30 minutes. func (s *Server) maybeLowBatteryAlert(ctx context.Context, rep model.LocationReport, nowMs int64) { if rep.Battery == nil || *rep.Battery >= 20 { return } since := nowMs - 30*60*1000 if has, _ := s.store.HasRecentAlert(ctx, rep.DeviceID, "low_battery", since); has { return } _, _ = s.store.CreateAlert(ctx, store.Alert{ DeviceID: rep.DeviceID, Type: "low_battery", Severity: "high", Message: fmt.Sprintf("Battery level: %d%%", *rep.Battery), CreatedAt: nowMs, }) } // batteryLog renders the optional battery value for the structured ingest log // — the integer percentage if known, an em-dash otherwise. func batteryLog(b *int) any { if b == nil { return "—" } return *b } // effectiveIntervalSec returns the device's capture interval — its per-device // override if set, otherwise the global default. func (s *Server) effectiveIntervalSec(ctx context.Context, deviceID string) int { def, _ := s.store.DefaultInterval(ctx) if dev, err := s.store.GetDevice(ctx, deviceID); err == nil && dev.CaptureIntervalSec != nil && *dev.CaptureIntervalSec > 0 { return *dev.CaptureIntervalSec } if def <= 0 { return 120 } return def }