Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions db/query.sql
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,15 @@ FROM user_vehicles
WHERE vehicle_id = $1
ORDER BY created_at DESC
LIMIT 1000;

-- name: GetLocationHistory :many
SELECT latitude, longitude, bearing, speed, accuracy, timestamp, trip_id, received_at
FROM location_points
WHERE vehicle_id = $1
AND timestamp >= $2
AND timestamp <= $3
ORDER BY timestamp DESC
LIMIT $4;

-- name: VehicleExists :one
SELECT EXISTS(SELECT 1 FROM vehicles WHERE id = $1);
75 changes: 75 additions & 0 deletions db/query.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

207 changes: 207 additions & 0 deletions location_history_handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package main

import (
"encoding/csv"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
)

const (
defaultHistoryLimit = 100
maxHistoryLimit = 1000
)

type locationHistoryResponse struct {
VehicleID string `json:"vehicle_id"`
Count int `json:"count"`
HasMore bool `json:"has_more"`
Locations []locationEntry `json:"locations"`
}

type locationEntry struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Bearing *float64 `json:"bearing"`
Speed *float64 `json:"speed"`
Accuracy *float64 `json:"accuracy"`
Timestamp int64 `json:"timestamp"`
TripID string `json:"trip_id"`
ReceivedAt string `json:"received_at"`
}

func handleGetLocationHistory(lister LocationHistoryLister, checker VehicleChecker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vehicleID := r.PathValue("vehicleID")
if vehicleID == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "vehicle_id is required"})
return
}
if len(vehicleID) > maxVehicleIDLength {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("vehicle_id must be at most %d characters", maxVehicleIDLength)})
return
}
if !vehicleIDPattern.MatchString(vehicleID) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "vehicle_id must contain only alphanumeric characters, dots, hyphens, and underscores"})
return
}

q := r.URL.Query()

to, err := parseOptionalInt64(q.Get("to"), time.Now().Unix())
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "to must be a valid unix timestamp"})
return
}
// Default from relative to to (not now) so a ?to= in the past selects
// the 24h window ending at to instead of tripping the from > to check.
from, err := parseOptionalInt64(q.Get("from"), to-86400)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "from must be a valid unix timestamp"})
return
}
if from > to {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "from must be less than or equal to to"})
return
}

limit, err := parseOptionalInt(q.Get("limit"), defaultHistoryLimit)
if err != nil || limit < 1 || limit > maxHistoryLimit {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("limit must be between 1 and %d", maxHistoryLimit)})
return
}

format := q.Get("format")
if format == "" {
format = "json"
}
if format != "json" && format != "csv" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "format must be json or csv"})
return
}

exists, err := checker.VehicleExists(r.Context(), vehicleID)
if err != nil {
slog.Error("failed to check vehicle existence", "vehicle_id", vehicleID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
return
}
if !exists {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "vehicle not found"})
return
}

// Fetch one extra row to detect whether results were truncated at limit.
points, err := lister.GetLocationHistory(r.Context(), vehicleID, from, to, limit+1)
if err != nil {
slog.Error("failed to get location history", "vehicle_id", vehicleID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"})
return
}
hasMore := len(points) > limit
if hasMore {
points = points[:limit]
}

if format == "csv" {
writeCSV(w, vehicleID, points)
return
}

entries := make([]locationEntry, 0, len(points))
for _, p := range points {
entries = append(entries, locationEntry{
Latitude: p.Latitude,
Longitude: p.Longitude,
Bearing: p.Bearing,
Speed: p.Speed,
Accuracy: p.Accuracy,
Timestamp: p.Timestamp,
TripID: p.TripID,
ReceivedAt: p.ReceivedAt.UTC().Format(time.RFC3339),
})
}

writeJSON(w, http.StatusOK, locationHistoryResponse{
VehicleID: vehicleID,
Count: len(entries),
HasMore: hasMore,
Locations: entries,
})
}
}

func writeCSV(w http.ResponseWriter, vehicleID string, points []LocationPoint) {
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s_locations.csv"`, vehicleID))
w.WriteHeader(http.StatusOK)

writer := csv.NewWriter(w)

header := []string{"timestamp", "latitude", "longitude", "bearing", "speed", "accuracy", "trip_id", "received_at"}
if err := writer.Write(header); err != nil {
slog.Error("failed to write CSV header", "vehicle_id", vehicleID, "error", err)
return
}

for _, p := range points {
record := []string{
strconv.FormatInt(p.Timestamp, 10),
strconv.FormatFloat(p.Latitude, 'f', -1, 64),
strconv.FormatFloat(p.Longitude, 'f', -1, 64),
formatOptionalFloat(p.Bearing),
formatOptionalFloat(p.Speed),
formatOptionalFloat(p.Accuracy),
sanitizeCSVCell(p.TripID),
p.ReceivedAt.UTC().Format(time.RFC3339),
}
if err := writer.Write(record); err != nil {
slog.Error("failed to write CSV record", "vehicle_id", vehicleID, "error", err)
return
}
}

writer.Flush()
if err := writer.Error(); err != nil {
slog.Error("failed to flush CSV response", "vehicle_id", vehicleID, "error", err)
}
}

// sanitizeCSVCell prevents CSV formula injection: cells beginning with =, +, -,
// @, tab, or CR are evaluated as formulas by Excel, LibreOffice, and Google
// Sheets. Prefixing with a single quote forces text interpretation. Only
// user-supplied text cells (trip_id) need this — numeric cells are
// server-formatted floats, and escaping them would corrupt negative values.
func sanitizeCSVCell(s string) string {
if s == "" {
return s
}
switch s[0] {
case '=', '+', '-', '@', '\t', '\r':
return "'" + s
}
return s
}

func formatOptionalFloat(v *float64) string {
if v == nil {
return ""
}
return strconv.FormatFloat(*v, 'f', -1, 64)
}

func parseOptionalInt64(s string, defaultVal int64) (int64, error) {
if s == "" {
return defaultVal, nil
}
return strconv.ParseInt(s, 10, 64)
}

func parseOptionalInt(s string, defaultVal int) (int, error) {
if s == "" {
return defaultVal, nil
}
return strconv.Atoi(s)
}
Loading
Loading