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:
tajniak81andClaude Opus 5.5 committed 2026-10-20 17:26:45 +02:00
1 parent e49f318a0e
commit 93f4a580e9
18 files changed
+1525 -43

No files matched your search

+360
View File
@@ -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)})
}