package api import ( "context" "encoding/json" "net/http" "net/url" "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"` Model string `json:"model"` Serial string `json:"serial"` 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"` Model string `json:"model"` Serial string `json:"serial"` 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"` } func (d droneRecord) view() droneView { return droneView{ ID: d.ID, Name: d.Name, Model: d.Model, Serial: d.Serial, 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"` // automatic | manual 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.Name } 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). // --------------------------------------------------------------------------- // effectiveLoggingPath is the stored path, or — when blank — derived from the // drone's capability (autologging → automatic, else manual). func effectiveLoggingPath(f flightRecord, d *droneRecord) string { if p := strings.TrimSpace(f.LoggingPath); p != "" { return p } if d != nil && d.AutologsFlights { return "automatic" } return "manual" } // 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 == "automatic" && 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 && c.LoggingPath == "manual" { 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"` } if _, err := s.listRecords(ctx, "drones", droneScopeFilter(who), "name", &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"` Model string `json:"model"` Serial string `json:"serial"` 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), "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, } } // 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 strings.TrimSpace(in.Name) == "" { writeError(w, http.StatusBadRequest, "drone name is required") 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()}) } // 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 strings.TrimSpace(in.Name) == "" { writeError(w, http.StatusBadRequest, "drone name is required") 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: strings.TrimSpace(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": strings.TrimSpace(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 a manual-path entry 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 } if effectiveLoggingPath(rec, d) == "manual" { if missing := missingManualFields(rec); len(missing) > 0 { return "manual 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) }