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>
1045 lines
37 KiB
Go
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)
|
|
}
|