Model Denmark's Dronebekendtgørelsen § 5 (on top of EU 2019/947) across all three tiers: schema, API Server, and Web App. Schema (migration 1720300700_add_logbook.js): two collections — `drones` (classification inputs: mtom, is_toy, autologs, c_class, operator no.) and `flights` (§5 minimum content + category/purpose/logging-path + operational maturity + a retention_until computed as operation_date + 5y). Locked API rules; access flows through the service account like users/orgs. API Server (logbook.go, logbook_export.go): /api/drones and /api/flights CRUD with per-role scoping in Go (user→own, admin→org, superadmin→all), plus GET /api/logbook/export (CSV — the "readable electronic format" for Trafikstyrelsen / pending police disclosure). Compliance is computed server-side per flight: exemption (toy / club-area / <250 g hobby), effective logging path, and red flags (autologs-without-FDR, specific-category-without- authorisation, missing §5 fields, past retention). Manual-path saves missing a §5 field are blocked (422). Web App: BFF proxies (export preserves the CSV Content-Type/Disposition), api.js client fns, and a Logbook.vue view (Flights/Drones tabs, inline forms, compliance badges + expandable detail, Export CSV) wired into Dashboard.vue, replacing the placeholder. Includes the rebuilt embedded dist bundle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
818 lines
28 KiB
Go
818 lines
28 KiB
Go
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)
|
|
}
|