Files
PilotVault/API Server/internal/api/logbook.go
T
tajniak81andClaude Opus 5.5 bc41d8b128 Split the automatic logging path by the app that logged the flight
logging_path is now manual, automatic_flyapp or automatic_flyagentapp. The
Fly App's entries are backed by the aircraft's flight recorder and keep the
missing-FDR red flag; the Fly Agent App's come from Remote ID, which is not a
flight recorder, so they must carry the § 5 fields like a manual entry. The API
maps the old "automatic" onto automatic_flyapp so Fly App builds already on
devices keep filing. The migration has been applied to the remote PocketBase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:36:16 +02:00

1045 lines
37 KiB
Go

package api
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// The logbook models Denmark's BEK nr. 1649 af 12/12/2023 ("Dronebekendtgørelsen")
// § 5 on top of the EU 2019/947 framework. Two collections back it: `drones`
// (airframes + the classification inputs that drive exemption / logging-path
// logic) and `flights` (the log entries). Like user/org management, all access
// flows through the superuser service account; per-role scoping is enforced here
// in Go, and the collections' own API rules stay locked.
//
// Scoping:
// - user → only their own flights; drones in their org (or unowned).
// - admin → all flights + drones in their organization.
// - superadmin → everything.
// requireUser gates a handler on any authenticated caller (and, like the other
// managed collections, on the service account being configured). The caller is
// stashed on the request context for the handler to read via caller(r).
func (s *Server) requireUser(next http.HandlerFunc) http.HandlerFunc {
return s.requireRole(next, func(c *callerIdentity) bool { return true }, "authentication required")
}
// ---------------------------------------------------------------------------
// PocketBase record shapes (snake_case, as stored) + client-facing views.
// ---------------------------------------------------------------------------
type droneRecord struct {
ID string `json:"id"`
Name string `json:"name"` // pilot's custom label; blank on auto-added drones
Model string `json:"model"`
// Serial is the number on the airframe — what the drone is registered under.
// The SDK cannot read it (see FlightControllerSerial), so it is hand-entered.
Serial string `json:"serial"`
// FlightControllerSerial is the only serial a connected aircraft reports, and
// so is what the auto-add path keys on. Stable per airframe, but not the
// registered serial and never shown as one.
FlightControllerSerial string `json:"flight_controller_serial"`
Firmware string `json:"firmware"`
ControllerFirmware string `json:"controller_firmware"`
Registration string `json:"registration"`
OperatorNumber string `json:"operator_number"`
MtomGrams float64 `json:"mtom_grams"`
IsToy bool `json:"is_toy"`
AutologsFlights bool `json:"autologs_flights"`
CClass string `json:"c_class"`
Organization string `json:"organization"`
Created string `json:"created"`
Updated string `json:"updated"`
}
type droneView struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Model string `json:"model"`
Serial string `json:"serial"`
FlightControllerSerial string `json:"flightControllerSerial"`
Firmware string `json:"firmware"`
ControllerFirmware string `json:"controllerFirmware"`
Registration string `json:"registration"`
OperatorNumber string `json:"operatorNumber"`
MtomGrams float64 `json:"mtomGrams"`
IsToy bool `json:"isToy"`
AutologsFlights bool `json:"autologsFlights"`
CClass string `json:"cClass"`
Organization string `json:"organization"`
Created string `json:"created"`
}
// displayName is what to call the drone in lists, logbook entries and the CSV
// export. The custom name wins; a drone auto-added on connection has none, so
// fall back to what the aircraft reported about itself.
func (d droneRecord) displayName() string {
// The airframe serial identifies the drone to a human, so it is preferred —
// but only the pilot can supply it, and an auto-added entry has nothing but
// the flight controller's, which is better than no distinguisher at all.
serial := strings.TrimSpace(d.Serial)
if serial == "" {
serial = strings.TrimSpace(d.FlightControllerSerial)
}
if n := strings.TrimSpace(d.Name); n != "" {
return n
}
if m := strings.TrimSpace(d.Model); m != "" {
if serial != "" {
return m + " · " + serial
}
return m
}
if serial != "" {
return serial
}
return "Unnamed drone"
}
func (d droneRecord) view() droneView {
return droneView{
ID: d.ID, Name: d.Name, DisplayName: d.displayName(), Model: d.Model, Serial: d.Serial,
FlightControllerSerial: d.FlightControllerSerial,
Firmware: d.Firmware, ControllerFirmware: d.ControllerFirmware,
Registration: d.Registration, OperatorNumber: d.OperatorNumber,
MtomGrams: d.MtomGrams, IsToy: d.IsToy,
AutologsFlights: d.AutologsFlights, CClass: d.CClass,
Organization: d.Organization, Created: d.Created,
}
}
type flightRecord struct {
ID string `json:"id"`
OperationDate string `json:"operation_date"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Drone string `json:"drone"`
AreaRoute string `json:"area_route"`
RouteGeoJSON json.RawMessage `json:"route_geojson"`
MaxAltitudeAGL float64 `json:"max_altitude_agl"`
RemotePilot string `json:"remote_pilot"`
PilotName string `json:"pilot_name"`
CertificateRef string `json:"certificate_ref"`
Category string `json:"category"`
Purpose string `json:"purpose"`
LoggingPath string `json:"logging_path"`
RawFDRLogURL string `json:"raw_fdr_log_url"`
AuthorisationRef string `json:"authorisation_ref"`
Weather string `json:"weather"`
AirspaceRef string `json:"airspace_ref"`
Observer string `json:"observer"`
Incidents string `json:"incidents"`
Notes string `json:"notes"`
Organization string `json:"organization"`
RetentionUntil string `json:"retention_until"`
Created string `json:"created"`
}
type flightView struct {
ID string `json:"id"`
OperationDate string `json:"operationDate"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Drone string `json:"drone"`
DroneName string `json:"droneName"`
AreaRoute string `json:"areaRoute"`
RouteGeoJSON json.RawMessage `json:"routeGeojson,omitempty"`
MaxAltitudeAGL float64 `json:"maxAltitudeAgl"`
RemotePilot string `json:"remotePilot"`
PilotName string `json:"pilotName"`
CertificateRef string `json:"certificateRef"`
Category string `json:"category"`
Purpose string `json:"purpose"`
LoggingPath string `json:"loggingPath"`
RawFDRLogURL string `json:"rawFdrLogUrl"`
AuthorisationRef string `json:"authorisationRef"`
Weather string `json:"weather"`
AirspaceRef string `json:"airspaceRef"`
Observer string `json:"observer"`
Incidents string `json:"incidents"`
Notes string `json:"notes"`
Organization string `json:"organization"`
RetentionUntil string `json:"retentionUntil"`
Created string `json:"created"`
Compliance compliance `json:"compliance"`
}
// compliance is the server-computed regulatory assessment for a single flight.
type compliance struct {
Required bool `json:"required"` // does § 5 require a logbook entry?
Exempt bool `json:"exempt"` // exempt from the logbook obligation
ExemptReason string `json:"exemptReason"` // why, when exempt
LoggingPath string `json:"loggingPath"` // manual | automatic_flyapp | automatic_flyagentapp
RedFlags []string `json:"redFlags"` // compliance gaps to surface
}
func (f flightRecord) view(drones map[string]droneRecord) flightView {
var d *droneRecord
if dr, ok := drones[f.Drone]; ok {
d = &dr
}
name := ""
if d != nil {
name = d.displayName()
}
return flightView{
ID: f.ID, OperationDate: f.OperationDate, StartTime: f.StartTime, EndTime: f.EndTime,
Drone: f.Drone, DroneName: name, AreaRoute: f.AreaRoute, RouteGeoJSON: f.RouteGeoJSON,
MaxAltitudeAGL: f.MaxAltitudeAGL, RemotePilot: f.RemotePilot, PilotName: f.PilotName,
CertificateRef: f.CertificateRef, Category: f.Category, Purpose: f.Purpose,
LoggingPath: f.LoggingPath, RawFDRLogURL: f.RawFDRLogURL, AuthorisationRef: f.AuthorisationRef,
Weather: f.Weather, AirspaceRef: f.AirspaceRef, Observer: f.Observer,
Incidents: f.Incidents, Notes: f.Notes, Organization: f.Organization,
RetentionUntil: f.RetentionUntil, Created: f.Created,
Compliance: computeCompliance(f, d),
}
}
// ---------------------------------------------------------------------------
// Compliance logic (BEK 1649 § 5 + the checklist's red flags).
// ---------------------------------------------------------------------------
// Logging paths: who produced the entry.
//
// manual: typed in by the pilot (Web App form).
//
// automatic_flyapp: recorded by the Fly App from the aircraft's own telemetry
// over the DJI SDK; the FDR log backs it.
//
// automatic_flyagentapp: recorded by the Fly Agent App from the aircraft's
// Remote ID broadcast. That is not a flight data recorder, so the entry must
// carry the § 5 minimum like a manual one.
const (
loggingManual = "manual"
loggingFlyApp = "automatic_flyapp"
loggingFlyAgentApp = "automatic_flyagentapp"
loggingLegacyAutomatic = "automatic" // pre-split value, still sent by older Fly App builds
)
// normalizeLoggingPath maps the pre-split "automatic" onto the Fly App path:
// it was the only app that logged automatically before the split.
func normalizeLoggingPath(p string) string {
p = strings.TrimSpace(p)
if p == loggingLegacyAutomatic {
return loggingFlyApp
}
return p
}
// effectiveLoggingPath is the stored path, or — when blank — derived from the
// drone's capability (autologging → the Fly App path, else manual).
func effectiveLoggingPath(f flightRecord, d *droneRecord) string {
if p := normalizeLoggingPath(f.LoggingPath); p != "" {
return p
}
if d != nil && d.AutologsFlights {
return loggingFlyApp
}
return loggingManual
}
// needsManualFields reports whether the path has no flight data recorder behind
// it, so the entry itself must hold every § 5 field.
func needsManualFields(path string) bool {
return path == loggingManual || path == loggingFlyAgentApp
}
// manualRequired are the § 5 minimum fields a manual-path entry must carry.
// Returns the human labels of any that are missing.
func missingManualFields(f flightRecord) []string {
var missing []string
if strings.TrimSpace(f.OperationDate) == "" {
missing = append(missing, "operation date")
}
if strings.TrimSpace(f.StartTime) == "" {
missing = append(missing, "start time")
}
if strings.TrimSpace(f.EndTime) == "" {
missing = append(missing, "end time")
}
if strings.TrimSpace(f.Drone) == "" {
missing = append(missing, "drone")
}
if strings.TrimSpace(f.AreaRoute) == "" {
missing = append(missing, "area or route flown")
}
if f.MaxAltitudeAGL <= 0 {
missing = append(missing, "maximum altitude (AGL)")
}
if strings.TrimSpace(f.PilotName) == "" {
missing = append(missing, "remote pilot name")
}
return missing
}
func computeCompliance(f flightRecord, d *droneRecord) compliance {
c := compliance{RedFlags: []string{}}
// 1. Exemption (BEK 1649 § 5 scope).
switch {
case d != nil && d.IsToy:
c.Exempt, c.ExemptReason = true, "toy drone"
case f.Purpose == "club_area":
c.Exempt, c.ExemptReason = true, "flown within a model-flying club's designated area"
case d != nil && d.MtomGrams > 0 && d.MtomGrams < 250 && f.Purpose == "hobby":
c.Exempt, c.ExemptReason = true, "private hobby flight under 250 g"
}
c.Required = !c.Exempt
// 2. Logging path.
c.LoggingPath = effectiveLoggingPath(f, d)
// 3. Red flags (compliance gaps, not just missing data).
if d != nil && d.AutologsFlights && c.LoggingPath == loggingFlyApp && strings.TrimSpace(f.RawFDRLogURL) == "" {
c.RedFlags = append(c.RedFlags, "Automatic-logging drone but no FDR log stored for this operation")
}
if f.Category == "specific" && strings.TrimSpace(f.AuthorisationRef) == "" {
c.RedFlags = append(c.RedFlags, "Specific-category flight with no linked authorisation reference")
}
if c.Required && needsManualFields(c.LoggingPath) {
for _, m := range missingManualFields(f) {
c.RedFlags = append(c.RedFlags, "Missing § 5 field: "+m)
}
}
if until := parseDay(f.RetentionUntil); !until.IsZero() && time.Now().After(until) {
c.RedFlags = append(c.RedFlags, "Past the 5-year retention window — archive before any cleanup")
}
return c
}
// parseDay parses the leading YYYY-MM-DD of a PocketBase date string.
func parseDay(s string) time.Time {
if len(s) >= 10 {
if t, err := time.Parse("2006-01-02", s[:10]); err == nil {
return t
}
}
return time.Time{}
}
// addFiveYears returns operation_date + 5 years as YYYY-MM-DD (the § 5 retention
// boundary, counted from the operation date). "" if the date can't be parsed.
func addFiveYears(dateStr string) string {
t := parseDay(dateStr)
if t.IsZero() {
return ""
}
return t.AddDate(5, 0, 0).Format("2006-01-02")
}
// ---------------------------------------------------------------------------
// PocketBase helpers.
// ---------------------------------------------------------------------------
// listRecords fetches a collection's records (up to 500) with an optional filter
// and sort, decoding items into out (a *struct{ Items []T }).
func (s *Server) listRecords(ctx context.Context, collection, filter, sort string, out any) (int, error) {
path := "/api/collections/" + collection + "/records?perPage=500"
if sort != "" {
path += "&sort=" + url.QueryEscape(sort)
}
if filter != "" {
path += "&filter=" + url.QueryEscape(filter)
}
data, status, err := s.admin.do(ctx, http.MethodGet, path, nil)
if err != nil {
return 0, err
}
if status != http.StatusOK {
return status, nil
}
return status, json.Unmarshal(data, out)
}
// dronesInScope returns an id→record map of the drones the caller may see.
func (s *Server) dronesInScope(ctx context.Context, who *callerIdentity) (map[string]droneRecord, error) {
var list struct {
Items []droneRecord `json:"items"`
}
// Sorted by creation, not name: the custom name is optional, so sorting by it
// would bunch every auto-added drone together under a blank key.
if _, err := s.listRecords(ctx, "drones", droneScopeFilter(who), "created", &list); err != nil {
return nil, err
}
m := make(map[string]droneRecord, len(list.Items))
for _, d := range list.Items {
m[d.ID] = d
}
return m, nil
}
func droneScopeFilter(who *callerIdentity) string {
if who.isSuperadmin() {
return ""
}
if who.OrgID != "" {
return "organization = \"" + who.OrgID + "\" || organization = \"\""
}
return "organization = \"\""
}
func flightScopeFilter(who *callerIdentity) string {
if who.isSuperadmin() {
return ""
}
if who.isManager() && who.OrgID != "" {
return "organization = \"" + who.OrgID + "\""
}
return "remote_pilot = \"" + who.ID + "\""
}
func canManageDrone(who *callerIdentity, d droneRecord) bool {
if who.isSuperadmin() {
return true
}
if who.OrgID != "" {
return d.Organization == who.OrgID
}
return d.Organization == ""
}
func canManageFlight(who *callerIdentity, f flightRecord) bool {
if who.isSuperadmin() {
return true
}
if who.isManager() && who.OrgID != "" && f.Organization == who.OrgID {
return true
}
return f.RemotePilot == who.ID
}
// getDrone fetches one drone record by id.
func (s *Server) getDrone(ctx context.Context, id string) (droneRecord, int, error) {
var d droneRecord
data, status, err := s.admin.do(ctx, http.MethodGet,
"/api/collections/drones/records/"+url.PathEscape(id), nil)
if err != nil {
return d, 0, err
}
if status == http.StatusOK {
_ = json.Unmarshal(data, &d)
}
return d, status, nil
}
// getFlight fetches one flight record by id.
func (s *Server) getFlight(ctx context.Context, id string) (flightRecord, int, error) {
var f flightRecord
data, status, err := s.admin.do(ctx, http.MethodGet,
"/api/collections/flights/records/"+url.PathEscape(id), nil)
if err != nil {
return f, 0, err
}
if status == http.StatusOK {
_ = json.Unmarshal(data, &f)
}
return f, status, nil
}
// gatewayError relays a PocketBase transport failure.
func gatewayError(w http.ResponseWriter, err error) {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach PocketBase", "detail": err.Error()})
}
// ---------------------------------------------------------------------------
// Drones CRUD.
// ---------------------------------------------------------------------------
// GET /api/drones — list drones in the caller's scope.
func (s *Server) handleListDrones(w http.ResponseWriter, r *http.Request) {
who := caller(r)
m, err := s.dronesInScope(r.Context(), who)
if err != nil {
gatewayError(w, err)
return
}
out := make([]droneView, 0, len(m))
for _, d := range m {
out = append(out, d.view())
}
writeJSON(w, http.StatusOK, map[string]any{"drones": out})
}
type droneInput struct {
Name string `json:"name"` // custom label; optional
Model string `json:"model"`
Serial string `json:"serial"`
FlightControllerSerial string `json:"flightControllerSerial"`
Firmware string `json:"firmware"`
ControllerFirmware string `json:"controllerFirmware"`
Registration string `json:"registration"`
OperatorNumber string `json:"operatorNumber"`
MtomGrams float64 `json:"mtomGrams"`
IsToy bool `json:"isToy"`
AutologsFlights bool `json:"autologsFlights"`
CClass string `json:"cClass"`
Organization *string `json:"organization"` // superadmin may target any org
}
func (in droneInput) payload(who *callerIdentity) map[string]any {
org := who.OrgID
if who.isSuperadmin() && in.Organization != nil {
org = strings.TrimSpace(*in.Organization)
}
return map[string]any{
"name": strings.TrimSpace(in.Name),
"model": strings.TrimSpace(in.Model),
"serial": strings.TrimSpace(in.Serial),
"flight_controller_serial": strings.TrimSpace(in.FlightControllerSerial),
"firmware": strings.TrimSpace(in.Firmware),
"controller_firmware": strings.TrimSpace(in.ControllerFirmware),
"registration": strings.TrimSpace(in.Registration),
"operator_number": strings.TrimSpace(in.OperatorNumber),
"mtom_grams": in.MtomGrams,
"is_toy": in.IsToy,
"autologs_flights": in.AutologsFlights,
"c_class": strings.TrimSpace(in.CClass),
"organization": org,
}
}
// identifiable reports whether the input says *anything* about which aircraft
// this is. The custom name is optional (auto-added drones have none), but a
// record with no name, model and serial is not a drone, it is an empty row.
func (in droneInput) identifiable() bool {
return strings.TrimSpace(in.Name) != "" ||
strings.TrimSpace(in.Model) != "" ||
strings.TrimSpace(in.Serial) != "" ||
strings.TrimSpace(in.FlightControllerSerial) != ""
}
// POST /api/drones — register a drone (assigned to the caller's org).
func (s *Server) handleCreateDrone(w http.ResponseWriter, r *http.Request) {
who := caller(r)
var in droneInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if !in.identifiable() {
writeError(w, http.StatusBadRequest, "give the drone a custom name, model or serial")
return
}
data, status, err := s.admin.do(r.Context(), http.MethodPost,
"/api/collections/drones/records", in.payload(who))
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
relayRaw(w, status, data)
return
}
var d droneRecord
_ = json.Unmarshal(data, &d)
writeJSON(w, http.StatusCreated, map[string]any{"drone": d.view()})
}
// autoDroneInput is the identity a connected aircraft reports about itself.
// Everything the pilot curates by hand (custom name, registration, MTOM, class)
// is deliberately absent — the auto path never touches those.
type autoDroneInput struct {
Model string `json:"model"`
// The airframe serial is absent by design: the SDK cannot read it, so an
// aircraft can only report its flight controller's.
FlightControllerSerial string `json:"flightControllerSerial"`
Firmware string `json:"firmware"`
ControllerFirmware string `json:"controllerFirmware"`
}
// findDroneByFCSerial looks a drone up across *all* orgs, ignoring caller scope:
// the flight controller's serial is unique per airframe, so the caller's own
// scope is not enough to know whether the record already exists.
func (s *Server) findDroneByFCSerial(ctx context.Context, fcSerial string) (droneRecord, bool, error) {
var list struct {
Items []droneRecord `json:"items"`
}
filter := "flight_controller_serial = " + strconv.Quote(fcSerial)
if _, err := s.listRecords(ctx, "drones", filter, "created", &list); err != nil {
return droneRecord{}, false, err
}
if len(list.Items) == 0 {
return droneRecord{}, false, nil
}
return list.Items[0], true, nil
}
// POST /api/drones/auto — upsert the drone the caller just connected, keyed by
// the flight controller's serial. Called by the Web App when a device reports a
// connected aircraft, so the fleet fills itself in without the pilot typing
// anything.
//
// It keys on the flight controller's serial rather than the airframe's because
// that is the only one an aircraft reports (MSDK v4 exposes no aircraft-level
// serial). The airframe serial — the registered one — stays blank here for the
// pilot to fill in on the Drones tab; guessing it from the flight controller's
// would put a wrong number on a compliance record.
//
// Idempotent by design: it runs on every connection event, so an existing entry
// is refreshed (firmware changes as the pilot updates the aircraft) rather than
// duplicated, and the response says which happened.
func (s *Server) handleAutoDrone(w http.ResponseWriter, r *http.Request) {
who := caller(r)
var in autoDroneInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
fcSerial := strings.TrimSpace(in.FlightControllerSerial)
if fcSerial == "" {
// No serial means no stable identity to key on — auto-adding here would
// mint a fresh drone on every reconnect.
writeError(w, http.StatusBadRequest, "flightControllerSerial is required to auto-add a drone")
return
}
existing, found, err := s.findDroneByFCSerial(r.Context(), fcSerial)
if err != nil {
gatewayError(w, err)
return
}
if found {
if !canManageDrone(who, existing) {
writeError(w, http.StatusConflict, "this drone is registered to another organisation")
return
}
// Refresh only what the aircraft is authoritative about, and only when it
// actually reported a value — a nil/absent field means "not resolved yet"
// (serial and firmware resolve on different schedules), never "cleared".
patch := map[string]any{}
if m := strings.TrimSpace(in.Model); m != "" && m != existing.Model {
patch["model"] = m
}
if f := strings.TrimSpace(in.Firmware); f != "" && f != existing.Firmware {
patch["firmware"] = f
}
if cf := strings.TrimSpace(in.ControllerFirmware); cf != "" && cf != existing.ControllerFirmware {
patch["controller_firmware"] = cf
}
if len(patch) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"drone": existing.view(), "created": false, "updated": false})
return
}
data, status, err := s.admin.do(r.Context(), http.MethodPatch,
"/api/collections/drones/records/"+url.PathEscape(existing.ID), patch)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
relayRaw(w, status, data)
return
}
var d droneRecord
_ = json.Unmarshal(data, &d)
writeJSON(w, http.StatusOK, map[string]any{"drone": d.view(), "created": false, "updated": true})
return
}
// New airframe: record what it reported and leave the curated fields blank
// for the pilot to fill in on the Drones tab.
payload := droneInput{
Model: strings.TrimSpace(in.Model),
FlightControllerSerial: fcSerial,
Firmware: strings.TrimSpace(in.Firmware),
ControllerFirmware: strings.TrimSpace(in.ControllerFirmware),
}.payload(who)
data, status, err := s.admin.do(r.Context(), http.MethodPost, "/api/collections/drones/records", payload)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
relayRaw(w, status, data)
return
}
var d droneRecord
_ = json.Unmarshal(data, &d)
writeJSON(w, http.StatusCreated, map[string]any{"drone": d.view(), "created": true, "updated": false})
}
// PATCH /api/drones/{id} — update a drone (must be in the caller's scope).
func (s *Server) handleUpdateDrone(w http.ResponseWriter, r *http.Request) {
who := caller(r)
id := r.PathValue("id")
existing, status, err := s.getDrone(r.Context(), id)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusNotFound, "drone not found")
return
}
if !canManageDrone(who, existing) {
writeError(w, http.StatusForbidden, "you cannot modify this drone")
return
}
var in droneInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if !in.identifiable() {
writeError(w, http.StatusBadRequest, "give the drone a custom name, model or serial")
return
}
// Preserve org ownership unless a superadmin explicitly retargets it.
payload := in.payload(who)
if !who.isSuperadmin() {
payload["organization"] = existing.Organization
}
data, status, err := s.admin.do(r.Context(), http.MethodPatch,
"/api/collections/drones/records/"+url.PathEscape(id), payload)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
relayRaw(w, status, data)
return
}
var d droneRecord
_ = json.Unmarshal(data, &d)
writeJSON(w, http.StatusOK, map[string]any{"drone": d.view()})
}
// DELETE /api/drones/{id} — delete a drone. Refused while flights reference it.
func (s *Server) handleDeleteDrone(w http.ResponseWriter, r *http.Request) {
who := caller(r)
id := r.PathValue("id")
existing, status, err := s.getDrone(r.Context(), id)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusNotFound, "drone not found")
return
}
if !canManageDrone(who, existing) {
writeError(w, http.StatusForbidden, "you cannot delete this drone")
return
}
// Guard: don't orphan logbook entries.
var refs struct {
TotalItems int `json:"totalItems"`
}
data, st, err := s.admin.do(r.Context(), http.MethodGet,
"/api/collections/flights/records?perPage=1&fields=id&filter="+
url.QueryEscape("drone = \""+id+"\""), nil)
if err != nil {
gatewayError(w, err)
return
}
if st == http.StatusOK {
_ = json.Unmarshal(data, &refs)
if refs.TotalItems > 0 {
writeError(w, http.StatusConflict, "drone still has logbook entries; delete or reassign them first")
return
}
}
_, st, err = s.admin.do(r.Context(), http.MethodDelete,
"/api/collections/drones/records/"+url.PathEscape(id), nil)
if err != nil {
gatewayError(w, err)
return
}
if st != http.StatusOK && st != http.StatusNoContent {
writeError(w, http.StatusBadGateway, "could not delete drone")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// ---------------------------------------------------------------------------
// Flights CRUD.
// ---------------------------------------------------------------------------
// GET /api/flights — list the caller's in-scope flights (newest first), each
// with its computed compliance assessment.
func (s *Server) handleListFlights(w http.ResponseWriter, r *http.Request) {
who := caller(r)
drones, err := s.dronesInScope(r.Context(), who)
if err != nil {
gatewayError(w, err)
return
}
var list struct {
Items []flightRecord `json:"items"`
}
if _, err := s.listRecords(r.Context(), "flights", flightScopeFilter(who),
"-operation_date,-start_time", &list); err != nil {
gatewayError(w, err)
return
}
out := make([]flightView, 0, len(list.Items))
for _, f := range list.Items {
out = append(out, f.view(drones))
}
writeJSON(w, http.StatusOK, map[string]any{"flights": out})
}
type flightInput struct {
OperationDate string `json:"operationDate"`
StartTime string `json:"startTime"`
EndTime string `json:"endTime"`
Drone string `json:"drone"`
AreaRoute string `json:"areaRoute"`
RouteGeoJSON json.RawMessage `json:"routeGeojson"`
MaxAltitudeAGL float64 `json:"maxAltitudeAgl"`
RemotePilot string `json:"remotePilot"` // managers may log for another pilot
PilotName string `json:"pilotName"`
CertificateRef string `json:"certificateRef"`
Category string `json:"category"`
Purpose string `json:"purpose"`
LoggingPath string `json:"loggingPath"`
RawFDRLogURL string `json:"rawFdrLogUrl"`
AuthorisationRef string `json:"authorisationRef"`
Weather string `json:"weather"`
AirspaceRef string `json:"airspaceRef"`
Observer string `json:"observer"`
Incidents string `json:"incidents"`
Notes string `json:"notes"`
}
// asRecord projects the input onto a flightRecord (used for validation before
// persisting). Pilot/org resolution happens in the handler.
func (in flightInput) asRecord() flightRecord {
return flightRecord{
OperationDate: strings.TrimSpace(in.OperationDate), StartTime: strings.TrimSpace(in.StartTime),
EndTime: strings.TrimSpace(in.EndTime), Drone: strings.TrimSpace(in.Drone),
AreaRoute: strings.TrimSpace(in.AreaRoute), MaxAltitudeAGL: in.MaxAltitudeAGL,
PilotName: strings.TrimSpace(in.PilotName), CertificateRef: strings.TrimSpace(in.CertificateRef),
Category: strings.TrimSpace(in.Category), Purpose: strings.TrimSpace(in.Purpose),
LoggingPath: normalizeLoggingPath(in.LoggingPath), RawFDRLogURL: strings.TrimSpace(in.RawFDRLogURL),
AuthorisationRef: strings.TrimSpace(in.AuthorisationRef),
}
}
func (in flightInput) payload(remotePilot, org, retentionUntil string) map[string]any {
p := map[string]any{
"operation_date": strings.TrimSpace(in.OperationDate),
"start_time": strings.TrimSpace(in.StartTime),
"end_time": strings.TrimSpace(in.EndTime),
"drone": strings.TrimSpace(in.Drone),
"area_route": strings.TrimSpace(in.AreaRoute),
"max_altitude_agl": in.MaxAltitudeAGL,
"remote_pilot": remotePilot,
"pilot_name": strings.TrimSpace(in.PilotName),
"certificate_ref": strings.TrimSpace(in.CertificateRef),
"category": strings.TrimSpace(in.Category),
"purpose": strings.TrimSpace(in.Purpose),
"logging_path": normalizeLoggingPath(in.LoggingPath),
"raw_fdr_log_url": strings.TrimSpace(in.RawFDRLogURL),
"authorisation_ref": strings.TrimSpace(in.AuthorisationRef),
"weather": strings.TrimSpace(in.Weather),
"airspace_ref": strings.TrimSpace(in.AirspaceRef),
"observer": strings.TrimSpace(in.Observer),
"incidents": strings.TrimSpace(in.Incidents),
"notes": strings.TrimSpace(in.Notes),
"organization": org,
"retention_until": retentionUntil,
}
if len(in.RouteGeoJSON) > 0 {
p["route_geojson"] = in.RouteGeoJSON
}
return p
}
// validateFlight enforces the § 5 minimum for the effective logging path. It
// returns an error message (and false) when an entry with no FDR behind it is
// incomplete — callers must block the save rather than store a silent partial
// record.
func validateFlight(rec flightRecord, d *droneRecord) (string, bool) {
if strings.TrimSpace(rec.OperationDate) == "" {
return "operation date is required", false
}
if strings.TrimSpace(rec.Drone) == "" {
return "a drone must be selected", false
}
switch effectiveLoggingPath(rec, d) {
case loggingManual, loggingFlyApp, loggingFlyAgentApp:
default:
return "logging path must be manual, automatic_flyapp or automatic_flyagentapp", false
}
if path := effectiveLoggingPath(rec, d); needsManualFields(path) {
if missing := missingManualFields(rec); len(missing) > 0 {
return path + " logbook entry is missing required § 5 field(s): " + strings.Join(missing, ", "), false
}
}
return "", true
}
// POST /api/flights — create a logbook entry.
func (s *Server) handleCreateFlight(w http.ResponseWriter, r *http.Request) {
who := caller(r)
var in flightInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
// Resolve + authorise the drone.
drone, status, err := s.getDrone(r.Context(), strings.TrimSpace(in.Drone))
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusBadRequest, "selected drone does not exist")
return
}
if !droneVisibleTo(who, drone) {
writeError(w, http.StatusForbidden, "selected drone is not in your scope")
return
}
// Pilot: default to the caller; a manager may log on behalf of another pilot.
pilot := who.ID
if who.isManager() && strings.TrimSpace(in.RemotePilot) != "" {
pilot = strings.TrimSpace(in.RemotePilot)
}
if strings.TrimSpace(in.PilotName) == "" {
in.PilotName = who.Email
}
rec := in.asRecord()
if msg, ok := validateFlight(rec, &drone); !ok {
writeError(w, http.StatusUnprocessableEntity, msg)
return
}
payload := in.payload(pilot, who.OrgID, addFiveYears(in.OperationDate))
data, status, err := s.admin.do(r.Context(), http.MethodPost,
"/api/collections/flights/records", payload)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
relayRaw(w, status, data)
return
}
var f flightRecord
_ = json.Unmarshal(data, &f)
writeJSON(w, http.StatusCreated, map[string]any{"flight": f.view(map[string]droneRecord{drone.ID: drone})})
}
// PATCH /api/flights/{id} — update a logbook entry.
func (s *Server) handleUpdateFlight(w http.ResponseWriter, r *http.Request) {
who := caller(r)
id := r.PathValue("id")
existing, status, err := s.getFlight(r.Context(), id)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusNotFound, "flight not found")
return
}
if !canManageFlight(who, existing) {
writeError(w, http.StatusForbidden, "you cannot modify this flight")
return
}
var in flightInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
drone, status, err := s.getDrone(r.Context(), strings.TrimSpace(in.Drone))
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusBadRequest, "selected drone does not exist")
return
}
if !droneVisibleTo(who, drone) {
writeError(w, http.StatusForbidden, "selected drone is not in your scope")
return
}
if strings.TrimSpace(in.PilotName) == "" {
in.PilotName = existing.PilotName
}
rec := in.asRecord()
if msg, ok := validateFlight(rec, &drone); !ok {
writeError(w, http.StatusUnprocessableEntity, msg)
return
}
// Preserve the original pilot + org; recompute retention from the new date.
payload := in.payload(existing.RemotePilot, existing.Organization, addFiveYears(in.OperationDate))
data, status, err := s.admin.do(r.Context(), http.MethodPatch,
"/api/collections/flights/records/"+url.PathEscape(id), payload)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
relayRaw(w, status, data)
return
}
var f flightRecord
_ = json.Unmarshal(data, &f)
writeJSON(w, http.StatusOK, map[string]any{"flight": f.view(map[string]droneRecord{drone.ID: drone})})
}
// DELETE /api/flights/{id} — delete a logbook entry.
func (s *Server) handleDeleteFlight(w http.ResponseWriter, r *http.Request) {
who := caller(r)
id := r.PathValue("id")
existing, status, err := s.getFlight(r.Context(), id)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusNotFound, "flight not found")
return
}
if !canManageFlight(who, existing) {
writeError(w, http.StatusForbidden, "you cannot delete this flight")
return
}
_, st, err := s.admin.do(r.Context(), http.MethodDelete,
"/api/collections/flights/records/"+url.PathEscape(id), nil)
if err != nil {
gatewayError(w, err)
return
}
if st != http.StatusOK && st != http.StatusNoContent {
writeError(w, http.StatusBadGateway, "could not delete flight")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// droneVisibleTo reports whether the caller may reference this drone on a flight
// (same rule as list scope: in the caller's org, or unowned; superadmin: any).
func droneVisibleTo(who *callerIdentity, d droneRecord) bool {
if who.isSuperadmin() {
return true
}
if who.OrgID != "" {
return d.Organization == who.OrgID || d.Organization == ""
}
return d.Organization == ""
}
// relayRaw relays a raw upstream body + status (used to surface PocketBase's own
// validation errors verbatim).
func relayRaw(w http.ResponseWriter, status int, data []byte) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(data)
}