Sync the Fly App's waypoint routes through the API Server
Routes saved in the Fly App's mission library now sync to a new per-user routes collection on the API Server, in the WaypointMissionSpec JSON format. The library pulls on load and pushes on every save and delete, merges last-writer-wins by updatedAt, keeps unacknowledged deletes as tombstones, and keeps working offline. Each signed-in account has its own copy on the device; routes saved while signed out go to the first account that signs in. The API Server gets list / get / upsert / delete route endpoints with a PocketBase migration, and the Web App a read-only Routes view on the dashboard (rebuilt dist). Tested against a fake PocketBase and a fake server only; not yet tried with a real sign-in on the tablet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e49f318a0e
commit
93f4a580e9
18 files changed
+1525
-43
No files matched your search
@@ -0,0 +1,360 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Routes are the pilots' saved waypoint missions — the server side of the Fly
|
||||
// App's mission library (GO 4-parity plan P7). One `routes` record holds one
|
||||
// route verbatim, exactly as `WaypointMissionSpec.toJson()` writes it (format
|
||||
// version 1), keyed by the app's own route id so a tablet can sync without
|
||||
// knowing PocketBase record ids.
|
||||
//
|
||||
// Sync is last-writer-wins by the route's own `updatedAt`: a write older than
|
||||
// what is stored is refused with 409 and the stored route, so the device adopts
|
||||
// it. Deletes are kept as tombstones (deleted = true, with the delete time as
|
||||
// updatedAt) so an offline device learns of them instead of re-uploading.
|
||||
//
|
||||
// Scoping (as for flights):
|
||||
// - user → only the routes they own.
|
||||
// - admin → every route in their organization.
|
||||
// - superadmin → everything.
|
||||
//
|
||||
// `?mine=1` narrows any caller to their own routes (what a tablet syncs).
|
||||
|
||||
// routeFormatVersion is the WaypointMissionSpec.formatVersion this server stores.
|
||||
const routeFormatVersion = 1
|
||||
|
||||
// maxRouteBytes caps one route's JSON (99 waypoints with actions is ~40 KB).
|
||||
const maxRouteBytes = 2 << 20
|
||||
|
||||
// routeIDPattern bounds the app-generated ids ("wp-<base36>"). It also keeps
|
||||
// the id safe to splice into a PocketBase filter.
|
||||
var routeIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,100}$`)
|
||||
|
||||
type routeRecord struct {
|
||||
ID string `json:"id"`
|
||||
RouteID string `json:"route_id"`
|
||||
Name string `json:"name"`
|
||||
Spec json.RawMessage `json:"spec"`
|
||||
RouteUpdatedAt string `json:"route_updated_at"`
|
||||
Deleted bool `json:"deleted"`
|
||||
Owner string `json:"owner"`
|
||||
Organization string `json:"organization"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
}
|
||||
|
||||
type routeView struct {
|
||||
ID string `json:"id"` // the app's route id
|
||||
RecordID string `json:"recordId"`
|
||||
Name string `json:"name"`
|
||||
Deleted bool `json:"deleted"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
PointCount int `json:"pointCount"`
|
||||
Owner string `json:"owner"`
|
||||
Mine bool `json:"mine"`
|
||||
Organization string `json:"organization"`
|
||||
Created string `json:"created"`
|
||||
Spec json.RawMessage `json:"spec,omitempty"`
|
||||
}
|
||||
|
||||
func (r routeRecord) view(who *callerIdentity) routeView {
|
||||
v := routeView{
|
||||
ID: r.RouteID, RecordID: r.ID, Name: r.Name, Deleted: r.Deleted,
|
||||
UpdatedAt: r.RouteUpdatedAt, Owner: r.Owner, Mine: who != nil && r.Owner == who.ID,
|
||||
Organization: r.Organization, Created: r.Created,
|
||||
}
|
||||
if len(r.Spec) > 0 && string(r.Spec) != "null" {
|
||||
v.Spec = r.Spec
|
||||
if s, err := parseRouteSpec(r.Spec); err == nil {
|
||||
v.PointCount = s.pointCount
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// routeSpec is what the server needs to read out of a stored route; the rest
|
||||
// of the JSON is kept as-is.
|
||||
type routeSpec struct {
|
||||
id string
|
||||
name string
|
||||
updatedAt time.Time
|
||||
pointCount int
|
||||
}
|
||||
|
||||
// parseRouteSpec checks a route body is a format-1 WaypointMissionSpec with an
|
||||
// id and an updatedAt, and pulls out the fields the server indexes on.
|
||||
func parseRouteSpec(raw []byte) (routeSpec, error) {
|
||||
var in struct {
|
||||
Version *int `json:"version"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Points []json.RawMessage `json:"points"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return routeSpec{}, errRoute("route is not valid JSON")
|
||||
}
|
||||
if in.Version == nil || *in.Version != routeFormatVersion {
|
||||
return routeSpec{}, errRoute("unsupported route format version (want 1)")
|
||||
}
|
||||
if !routeIDPattern.MatchString(in.ID) {
|
||||
return routeSpec{}, errRoute("route id is missing or invalid")
|
||||
}
|
||||
t, ok := parseRouteTime(in.UpdatedAt)
|
||||
if !ok {
|
||||
return routeSpec{}, errRoute("route updatedAt is missing or not an RFC 3339 time")
|
||||
}
|
||||
return routeSpec{id: in.ID, name: strings.TrimSpace(in.Name), updatedAt: t, pointCount: len(in.Points)}, nil
|
||||
}
|
||||
|
||||
type errRoute string
|
||||
|
||||
func (e errRoute) Error() string { return string(e) }
|
||||
|
||||
// routeTimeLayout is how route_updated_at is stored: fixed-width UTC with
|
||||
// microseconds (Dart's DateTime precision), so it also sorts as text.
|
||||
const routeTimeLayout = "2006-01-02T15:04:05.000000Z"
|
||||
|
||||
func parseRouteTime(s string) (time.Time, bool) {
|
||||
t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(s))
|
||||
return t, err == nil
|
||||
}
|
||||
|
||||
// routeIsNewer reports whether a stored route (or tombstone) stamped `stored`
|
||||
// supersedes a write stamped `incoming`. Equal stamps let the write through, so
|
||||
// a retried push is idempotent. An unreadable stored stamp never blocks.
|
||||
func routeIsNewer(stored string, incoming time.Time) bool {
|
||||
t, ok := parseRouteTime(stored)
|
||||
return ok && t.After(incoming)
|
||||
}
|
||||
|
||||
func routeScopeFilter(who *callerIdentity, mine bool) string {
|
||||
if mine || (!who.isSuperadmin() && !(who.isManager() && who.OrgID != "")) {
|
||||
return "owner = \"" + who.ID + "\""
|
||||
}
|
||||
if who.isSuperadmin() {
|
||||
return ""
|
||||
}
|
||||
return "organization = \"" + who.OrgID + "\""
|
||||
}
|
||||
|
||||
func canManageRoute(who *callerIdentity, r routeRecord) bool {
|
||||
if who.isSuperadmin() {
|
||||
return true
|
||||
}
|
||||
if who.isManager() && who.OrgID != "" && r.Organization == who.OrgID {
|
||||
return true
|
||||
}
|
||||
return r.Owner == who.ID
|
||||
}
|
||||
|
||||
// findRoute fetches a route by the app's route id (tombstones included).
|
||||
func (s *Server) findRoute(ctx context.Context, routeID string) (routeRecord, bool, error) {
|
||||
var list struct {
|
||||
Items []routeRecord `json:"items"`
|
||||
}
|
||||
status, err := s.listRecords(ctx, "routes", "route_id = \""+routeID+"\"", "", &list)
|
||||
if err != nil {
|
||||
return routeRecord{}, false, err
|
||||
}
|
||||
if status != http.StatusOK || len(list.Items) == 0 {
|
||||
return routeRecord{}, false, nil
|
||||
}
|
||||
return list.Items[0], true, nil
|
||||
}
|
||||
|
||||
// routePathID reads and validates the {id} path value.
|
||||
func routePathID(w http.ResponseWriter, r *http.Request) (string, bool) {
|
||||
id := r.PathValue("id")
|
||||
if !routeIDPattern.MatchString(id) {
|
||||
writeError(w, http.StatusBadRequest, "invalid route id")
|
||||
return "", false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// GET /api/routes — the caller's in-scope routes, newest first. Tombstones are
|
||||
// left out unless ?includeDeleted=1 (a syncing device needs them).
|
||||
func (s *Server) handleListRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
q := r.URL.Query()
|
||||
filter := routeScopeFilter(who, q.Get("mine") == "1")
|
||||
if q.Get("includeDeleted") != "1" {
|
||||
if filter != "" {
|
||||
filter = "(" + filter + ") && "
|
||||
}
|
||||
filter += "deleted = false"
|
||||
}
|
||||
var list struct {
|
||||
Items []routeRecord `json:"items"`
|
||||
}
|
||||
status, err := s.listRecords(r.Context(), "routes", filter, "-route_updated_at", &list)
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
writeError(w, http.StatusBadGateway, "could not list routes")
|
||||
return
|
||||
}
|
||||
out := make([]routeView, 0, len(list.Items))
|
||||
for _, rec := range list.Items {
|
||||
out = append(out, rec.view(who))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"routes": out})
|
||||
}
|
||||
|
||||
// GET /api/routes/{id} — one route (a tombstone reads as deleted: true).
|
||||
func (s *Server) handleGetRoute(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
id, ok := routePathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rec, found, err := s.findRoute(r.Context(), id)
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
}
|
||||
if !found || !canManageRoute(who, rec) {
|
||||
writeError(w, http.StatusNotFound, "route not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"route": rec.view(who)})
|
||||
}
|
||||
|
||||
// PUT /api/routes/{id} — create or update a route. The body is the route JSON
|
||||
// as WaypointMissionSpec.toJson() writes it; its id must match the path.
|
||||
func (s *Server) handlePutRoute(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
id, ok := routePathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRouteBytes))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusRequestEntityTooLarge, "route is too large")
|
||||
return
|
||||
}
|
||||
spec, err := parseRouteSpec(raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||
return
|
||||
}
|
||||
if spec.id != id {
|
||||
writeError(w, http.StatusUnprocessableEntity, "route id does not match the URL")
|
||||
return
|
||||
}
|
||||
|
||||
existing, found, err := s.findRoute(r.Context(), id)
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"route_id": id,
|
||||
"name": spec.name,
|
||||
"spec": json.RawMessage(raw),
|
||||
"route_updated_at": spec.updatedAt.UTC().Format(routeTimeLayout),
|
||||
"deleted": false,
|
||||
}
|
||||
|
||||
if !found {
|
||||
payload["owner"] = who.ID
|
||||
payload["organization"] = who.OrgID
|
||||
data, status, err := s.admin.do(r.Context(), http.MethodPost, "/api/collections/routes/records", payload)
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
relayRaw(w, status, data)
|
||||
return
|
||||
}
|
||||
var rec routeRecord
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"route": rec.view(who)})
|
||||
return
|
||||
}
|
||||
|
||||
if !canManageRoute(who, existing) {
|
||||
writeError(w, http.StatusConflict, "route id is already in use")
|
||||
return
|
||||
}
|
||||
if routeIsNewer(existing.RouteUpdatedAt, spec.updatedAt) {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{"error": "a newer version of this route is stored", "route": existing.view(who)})
|
||||
return
|
||||
}
|
||||
// Owner + organization stay as created: an admin editing a pilot's route
|
||||
// does not take it over.
|
||||
data, status, err := s.admin.do(r.Context(), http.MethodPatch,
|
||||
"/api/collections/routes/records/"+url.PathEscape(existing.ID), payload)
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
relayRaw(w, status, data)
|
||||
return
|
||||
}
|
||||
var rec routeRecord
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"route": rec.view(who)})
|
||||
}
|
||||
|
||||
// DELETE /api/routes/{id}?at=<RFC 3339> — tombstone a route. `at` is when the
|
||||
// pilot deleted it (default: now); a route edited after that is kept (409).
|
||||
func (s *Server) handleDeleteRoute(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
id, ok := routePathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
at := time.Now().UTC()
|
||||
if v := r.URL.Query().Get("at"); v != "" {
|
||||
t, ok := parseRouteTime(v)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "at must be an RFC 3339 time")
|
||||
return
|
||||
}
|
||||
at = t
|
||||
}
|
||||
existing, found, err := s.findRoute(r.Context(), id)
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
}
|
||||
if !found || !canManageRoute(who, existing) {
|
||||
writeError(w, http.StatusNotFound, "route not found")
|
||||
return
|
||||
}
|
||||
if routeIsNewer(existing.RouteUpdatedAt, at) {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{"error": "the route was changed after this delete", "route": existing.view(who)})
|
||||
return
|
||||
}
|
||||
data, status, err := s.admin.do(r.Context(), http.MethodPatch,
|
||||
"/api/collections/routes/records/"+url.PathEscape(existing.ID), map[string]any{
|
||||
"deleted": true,
|
||||
"route_updated_at": at.UTC().Format(routeTimeLayout),
|
||||
})
|
||||
if err != nil {
|
||||
gatewayError(w, err)
|
||||
return
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
relayRaw(w, status, data)
|
||||
return
|
||||
}
|
||||
var rec routeRecord
|
||||
_ = json.Unmarshal(data, &rec)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "route": rec.view(who)})
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A route as the Fly App's WaypointMissionSpec.toJson() writes it.
|
||||
const sampleRoute = `{"version":1,"id":"wp-m1x2y3","name":" Field ","points":[{"lat":55.1,"lon":12.2},{"lat":55.2,"lon":12.3}],"pois":[],` +
|
||||
`"cruiseSpeed":8.0,"headingMode":"auto","finishAction":"goHome","pathMode":"straight","exitOnSignalLost":true,` +
|
||||
`"gotoFirst":"safely","repeatTimes":1,"updatedAt":"2026-10-20T08:30:00.123Z"}`
|
||||
|
||||
func TestParseRouteSpec(t *testing.T) {
|
||||
s, err := parseRouteSpec([]byte(sampleRoute))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if s.id != "wp-m1x2y3" || s.name != "Field" || s.pointCount != 2 {
|
||||
t.Fatalf("got %+v", s)
|
||||
}
|
||||
want := time.Date(2026, 10, 20, 8, 30, 0, 123e6, time.UTC)
|
||||
if !s.updatedAt.Equal(want) {
|
||||
t.Fatalf("updatedAt = %v, want %v", s.updatedAt, want)
|
||||
}
|
||||
|
||||
bad := map[string]string{
|
||||
"not json": `{`,
|
||||
"no version": `{"id":"wp-1","updatedAt":"2026-10-20T08:30:00Z"}`,
|
||||
"version 2": `{"version":2,"id":"wp-1","updatedAt":"2026-10-20T08:30:00Z"}`,
|
||||
"no id": `{"version":1,"updatedAt":"2026-10-20T08:30:00Z"}`,
|
||||
"filter quote": `{"version":1,"id":"a\" || 1=1","updatedAt":"2026-10-20T08:30:00Z"}`,
|
||||
"no updatedAt": `{"version":1,"id":"wp-1"}`,
|
||||
"bad updatedAt": `{"version":1,"id":"wp-1","updatedAt":"yesterday"}`,
|
||||
}
|
||||
for name, body := range bad {
|
||||
if _, err := parseRouteSpec([]byte(body)); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteIsNewer(t *testing.T) {
|
||||
at := time.Date(2026, 10, 20, 8, 30, 0, 0, time.UTC)
|
||||
if !routeIsNewer("2026-10-20T08:30:00.000001Z", at) {
|
||||
t.Error("a later stored stamp must win")
|
||||
}
|
||||
if routeIsNewer("2026-10-20T08:30:00.000000Z", at) {
|
||||
t.Error("an equal stamp lets the write through (idempotent retry)")
|
||||
}
|
||||
if routeIsNewer("2026-10-20T08:29:59Z", at) {
|
||||
t.Error("an older stored stamp loses")
|
||||
}
|
||||
if routeIsNewer("", at) {
|
||||
t.Error("a missing stored stamp never blocks")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteTimeLayoutSortsAsText(t *testing.T) {
|
||||
a := time.Date(2026, 10, 20, 8, 30, 0, 0, time.UTC).Format(routeTimeLayout)
|
||||
b := time.Date(2026, 10, 20, 8, 30, 0, 500e6, time.UTC).Format(routeTimeLayout)
|
||||
if !(a < b) {
|
||||
t.Fatalf("%q should sort before %q", a, b)
|
||||
}
|
||||
if _, ok := parseRouteTime(a); !ok {
|
||||
t.Fatalf("stored stamp %q does not parse back", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteScope(t *testing.T) {
|
||||
user := &callerIdentity{ID: "u1", Role: roleUser, OrgID: "o1"}
|
||||
admin := &callerIdentity{ID: "a1", Role: roleAdmin, OrgID: "o1"}
|
||||
orgless := &callerIdentity{ID: "a2", Role: roleAdmin}
|
||||
super := &callerIdentity{ID: "s1", Role: roleSuperadmin}
|
||||
|
||||
cases := []struct {
|
||||
who *callerIdentity
|
||||
mine bool
|
||||
want string
|
||||
}{
|
||||
{user, false, `owner = "u1"`},
|
||||
{admin, false, `organization = "o1"`},
|
||||
{admin, true, `owner = "a1"`},
|
||||
{orgless, false, `owner = "a2"`},
|
||||
{super, false, ``},
|
||||
{super, true, `owner = "s1"`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := routeScopeFilter(c.who, c.mine); got != c.want {
|
||||
t.Errorf("%s mine=%v: %q, want %q", c.who.ID, c.mine, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
pilots := routeRecord{Owner: "u1", Organization: "o1"}
|
||||
other := routeRecord{Owner: "u9", Organization: "o2"}
|
||||
if !canManageRoute(user, pilots) || canManageRoute(user, routeRecord{Owner: "u2", Organization: "o1"}) {
|
||||
t.Error("a user manages only their own routes")
|
||||
}
|
||||
if !canManageRoute(admin, pilots) || canManageRoute(admin, other) {
|
||||
t.Error("an admin manages their organization's routes only")
|
||||
}
|
||||
if !canManageRoute(super, other) {
|
||||
t.Error("a superadmin manages everything")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRoutesPB is just enough PocketBase for the route handlers: superuser
|
||||
// auth, a route_id lookup, create and patch.
|
||||
type fakeRoutesPB struct {
|
||||
mu sync.Mutex
|
||||
recs map[string]map[string]any // record id → fields
|
||||
n int
|
||||
}
|
||||
|
||||
func (f *fakeRoutesPB) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
const coll = "/api/collections/routes/records"
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/auth-with-password"):
|
||||
writeJSON(w, 200, map[string]any{"token": "su"})
|
||||
case r.Method == http.MethodGet && r.URL.Path == coll:
|
||||
items := []map[string]any{}
|
||||
filter := r.URL.Query().Get("filter")
|
||||
for _, rec := range f.recs {
|
||||
if strings.HasPrefix(filter, "route_id = ") && filter != `route_id = "`+rec["route_id"].(string)+`"` {
|
||||
continue
|
||||
}
|
||||
items = append(items, rec)
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"items": items})
|
||||
case r.Method == http.MethodPost && r.URL.Path == coll:
|
||||
var rec map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&rec)
|
||||
f.n++
|
||||
rec["id"] = "rec" + strconv.Itoa(f.n)
|
||||
f.recs[rec["id"].(string)] = rec
|
||||
writeJSON(w, 200, rec)
|
||||
case r.Method == http.MethodPatch && strings.HasPrefix(r.URL.Path, coll+"/"):
|
||||
rec, ok := f.recs[strings.TrimPrefix(r.URL.Path, coll+"/")]
|
||||
if !ok {
|
||||
writeError(w, 404, "missing")
|
||||
return
|
||||
}
|
||||
var patch map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&patch)
|
||||
for k, v := range patch {
|
||||
rec[k] = v
|
||||
}
|
||||
writeJSON(w, 200, rec)
|
||||
default:
|
||||
writeError(w, 404, "unexpected "+r.Method+" "+r.URL.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteHandlersLastWriterWins(t *testing.T) {
|
||||
pb := &fakeRoutesPB{recs: map[string]map[string]any{}}
|
||||
ts := httptest.NewServer(pb)
|
||||
defer ts.Close()
|
||||
s := &Server{admin: newAdminClient(ts.URL, "svc@x", "pw")}
|
||||
pilot := &callerIdentity{ID: "u1", Role: roleUser, OrgID: "o1"}
|
||||
stranger := &callerIdentity{ID: "u2", Role: roleUser, OrgID: "o2"}
|
||||
|
||||
call := func(who *callerIdentity, h http.HandlerFunc, method, target, body string) (int, map[string]any) {
|
||||
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||||
req.SetPathValue("id", strings.Split(strings.TrimPrefix(req.URL.Path, "/api/routes/"), "?")[0])
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxCaller, who))
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, req)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec.Code, out
|
||||
}
|
||||
withTime := func(at string) string {
|
||||
return strings.Replace(sampleRoute, "2026-10-20T08:30:00.123Z", at, 1)
|
||||
}
|
||||
|
||||
if code, _ := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-other", sampleRoute); code != 422 {
|
||||
t.Fatalf("id mismatch: %d", code)
|
||||
}
|
||||
code, out := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", sampleRoute)
|
||||
if code != 201 {
|
||||
t.Fatalf("create: %d %v", code, out)
|
||||
}
|
||||
route := out["route"].(map[string]any)
|
||||
if route["id"] != "wp-m1x2y3" || route["name"] != "Field" || route["mine"] != true || route["pointCount"].(float64) != 2 {
|
||||
t.Fatalf("created view: %v", route)
|
||||
}
|
||||
stored := pb.recs["rec1"]
|
||||
if stored["owner"] != "u1" || stored["organization"] != "o1" {
|
||||
t.Fatalf("owner/org not stamped: %v", stored)
|
||||
}
|
||||
|
||||
if code, out := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", withTime("2026-10-20T08:00:00Z")); code != 409 || out["route"] == nil {
|
||||
t.Fatalf("older write: %d %v", code, out)
|
||||
}
|
||||
if code, _ := call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", sampleRoute); code != 200 {
|
||||
t.Fatalf("idempotent retry: %d", code)
|
||||
}
|
||||
if code, _ := call(stranger, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", withTime("2026-10-21T00:00:00Z")); code != 409 {
|
||||
t.Fatalf("someone else's id: %d", code)
|
||||
}
|
||||
if code, _ := call(stranger, s.handleGetRoute, "GET", "/api/routes/wp-m1x2y3", ""); code != 404 {
|
||||
t.Fatalf("stranger get: %d", code)
|
||||
}
|
||||
|
||||
if code, _ := call(pilot, s.handleDeleteRoute, "DELETE", "/api/routes/wp-m1x2y3?at=2026-10-20T08:00:00Z", ""); code != 409 {
|
||||
t.Fatalf("delete older than the edit: %d", code)
|
||||
}
|
||||
code, out = call(pilot, s.handleDeleteRoute, "DELETE", "/api/routes/wp-m1x2y3?at=2026-10-20T09:00:00Z", "")
|
||||
if code != 200 || out["route"].(map[string]any)["deleted"] != true {
|
||||
t.Fatalf("delete: %d %v", code, out)
|
||||
}
|
||||
if stored["route_updated_at"] != "2026-10-20T09:00:00.000000Z" {
|
||||
t.Fatalf("tombstone stamp: %v", stored["route_updated_at"])
|
||||
}
|
||||
// A save newer than the delete brings the route back.
|
||||
code, out = call(pilot, s.handlePutRoute, "PUT", "/api/routes/wp-m1x2y3", withTime("2026-10-20T10:00:00Z"))
|
||||
if code != 200 || out["route"].(map[string]any)["deleted"] != false {
|
||||
t.Fatalf("resurrect: %d %v", code, out)
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,14 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("DELETE /api/documents/{id}", s.requireUser(s.handleDeleteDocument))
|
||||
mux.HandleFunc("GET /api/documents/{id}/file", s.requireUser(s.handleDownloadDocument))
|
||||
|
||||
// Routes — saved waypoint missions, synced with the Fly App's mission
|
||||
// library (last-writer-wins by the route's updatedAt; deletes tombstoned).
|
||||
// Per-role scoping is enforced inside the handlers.
|
||||
mux.HandleFunc("GET /api/routes", s.requireUser(s.handleListRoutes))
|
||||
mux.HandleFunc("GET /api/routes/{id}", s.requireUser(s.handleGetRoute))
|
||||
mux.HandleFunc("PUT /api/routes/{id}", s.requireUser(s.handlePutRoute))
|
||||
mux.HandleFunc("DELETE /api/routes/{id}", s.requireUser(s.handleDeleteRoute))
|
||||
|
||||
// Device / dashboard API.
|
||||
mux.HandleFunc("GET /api/devices", s.handleListDevices)
|
||||
mux.HandleFunc("GET /api/devices/{id}/track", s.handleTrack)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
// Creates the `routes` collection: the pilots' saved waypoint missions (the Fly
|
||||
// App's mission library, GO 4-parity plan P7). Each row holds one route exactly
|
||||
// as the Fly App's `WaypointMissionSpec.toJson()` writes it (format version 1)
|
||||
// in `spec`, keyed by the app-generated `route_id` so a tablet can push and pull
|
||||
// without knowing PocketBase record ids.
|
||||
//
|
||||
// Sync is last-writer-wins by the route's own `updatedAt` (copied into
|
||||
// `route_updated_at` so the API Server can compare without parsing `spec`).
|
||||
// Deletes are kept as tombstones (`deleted` = true) so a device that was
|
||||
// offline learns about them instead of re-uploading the route.
|
||||
//
|
||||
// Like the logbook and documents, the collection is reached only through the
|
||||
// API Server's superuser service account, so its API rules stay locked
|
||||
// (superusers only); the API Server enforces per-role scoping in Go.
|
||||
//
|
||||
// Apply by copying into your PocketBase deployment's `pb_migrations/` directory
|
||||
// and restarting. Written for PocketBase v0.22+/v0.23. Idempotent: the
|
||||
// collection is created only if absent, so re-running is a no-op.
|
||||
//
|
||||
// Depends on 1720300200_add_organizations.js (organizations) and the `users`
|
||||
// auth collection.
|
||||
migrate(
|
||||
(app) => {
|
||||
// Idempotency guard.
|
||||
try {
|
||||
app.findCollectionByNameOrId('routes')
|
||||
return // already present
|
||||
} catch (_) {
|
||||
// create below
|
||||
}
|
||||
|
||||
const orgs = app.findCollectionByNameOrId('organizations')
|
||||
const users = app.findCollectionByNameOrId('users')
|
||||
|
||||
const routes = new Collection({
|
||||
type: 'base',
|
||||
name: 'routes',
|
||||
fields: [
|
||||
// The Fly App's own id (e.g. "wp-m1x2y3z"); unique across the store.
|
||||
{ name: 'route_id', type: 'text', required: true, max: 100 },
|
||||
{ name: 'name', type: 'text', max: 200, presentable: true },
|
||||
// The route itself, verbatim WaypointMissionSpec.toJson().
|
||||
{ name: 'spec', type: 'json', maxSize: 2000000 },
|
||||
// The spec's updatedAt (RFC 3339, UTC) — the last-writer-wins clock.
|
||||
{ name: 'route_updated_at', type: 'text', max: 40 },
|
||||
{ name: 'deleted', type: 'bool' },
|
||||
{
|
||||
name: 'owner',
|
||||
type: 'relation',
|
||||
required: false,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
presentable: false,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'relation',
|
||||
required: false,
|
||||
collectionId: orgs.id,
|
||||
cascadeDelete: false,
|
||||
minSelect: 0,
|
||||
maxSelect: 1,
|
||||
presentable: false,
|
||||
},
|
||||
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
|
||||
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
|
||||
],
|
||||
indexes: [
|
||||
'CREATE UNIQUE INDEX `idx_routes_route_id` ON `routes` (`route_id`)',
|
||||
'CREATE INDEX `idx_routes_owner` ON `routes` (`owner`)',
|
||||
'CREATE INDEX `idx_routes_org` ON `routes` (`organization`)',
|
||||
],
|
||||
})
|
||||
app.save(routes)
|
||||
},
|
||||
(app) => {
|
||||
try {
|
||||
app.delete(app.findCollectionByNameOrId('routes'))
|
||||
} catch (_) {
|
||||
// already gone
|
||||
}
|
||||
},
|
||||
)
|
||||
Reference in new issue
Block a user