Add drone-pilot logbook system (BEK 1649 §5)
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
407e34bf0d
commit
e9b27530ec
@@ -0,0 +1,817 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GET /api/logbook/export — exports the caller's in-scope logbook as CSV.
|
||||
//
|
||||
// This is the "readable electronic format" disclosure path required by BEK 1649
|
||||
// § 5: retained 5 years and producible on request from Trafikstyrelsen (and,
|
||||
// under the 2026 hearing draft, the police). CSV is an open format, so it holds
|
||||
// regardless of whether the source records came from a manual entry or an
|
||||
// automatic FDR export.
|
||||
func (s *Server) handleExportLogbook(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
|
||||
}
|
||||
|
||||
filename := "pilotvault-logbook-" + time.Now().Format("2006-01-02") + ".csv"
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
cw := csv.NewWriter(w)
|
||||
defer cw.Flush()
|
||||
|
||||
_ = cw.Write([]string{
|
||||
"operation_date", "start_time", "end_time",
|
||||
"drone_name", "drone_model", "drone_serial", "operator_number",
|
||||
"area_or_route", "max_altitude_agl_m",
|
||||
"remote_pilot", "certificate_ref",
|
||||
"category", "purpose", "logging_path", "fdr_log_url", "authorisation_ref",
|
||||
"weather", "airspace_ref", "observer", "incidents", "notes",
|
||||
"retention_until", "logbook_required", "compliance_flags",
|
||||
})
|
||||
|
||||
for _, f := range list.Items {
|
||||
var d *droneRecord
|
||||
if dr, ok := drones[f.Drone]; ok {
|
||||
d = &dr
|
||||
}
|
||||
c := computeCompliance(f, d)
|
||||
droneName, model, serial, opNo := "", "", "", ""
|
||||
if d != nil {
|
||||
droneName, model, serial, opNo = d.Name, d.Model, d.Serial, d.OperatorNumber
|
||||
}
|
||||
alt := ""
|
||||
if f.MaxAltitudeAGL > 0 {
|
||||
alt = strconv.FormatFloat(f.MaxAltitudeAGL, 'f', -1, 64)
|
||||
}
|
||||
_ = cw.Write([]string{
|
||||
day(f.OperationDate), f.StartTime, f.EndTime,
|
||||
droneName, model, serial, opNo,
|
||||
f.AreaRoute, alt,
|
||||
f.PilotName, f.CertificateRef,
|
||||
f.Category, f.Purpose, c.LoggingPath, f.RawFDRLogURL, f.AuthorisationRef,
|
||||
f.Weather, f.AirspaceRef, f.Observer, f.Incidents, f.Notes,
|
||||
day(f.RetentionUntil), boolText(c.Required), strings.Join(c.RedFlags, "; "),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// day trims a PocketBase datetime string to its YYYY-MM-DD date.
|
||||
func day(s string) string {
|
||||
if len(s) >= 10 {
|
||||
return s[:10]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func boolText(b bool) string {
|
||||
if b {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
@@ -140,6 +140,19 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("DELETE /api/admin/plugins/{name}", s.requireSuperadminAuth(s.handleDeletePlugin))
|
||||
mux.HandleFunc("POST /api/admin/plugins/{name}/health", s.requireSuperadminAuth(s.handlePluginHealth))
|
||||
|
||||
// Logbook — drones + flights (BEK 1649 §5). Available to any authenticated
|
||||
// user; per-role scoping (user→own, admin→org, superadmin→all) is enforced
|
||||
// inside the handlers, so the shared requireUser gate suffices.
|
||||
mux.HandleFunc("GET /api/drones", s.requireUser(s.handleListDrones))
|
||||
mux.HandleFunc("POST /api/drones", s.requireUser(s.handleCreateDrone))
|
||||
mux.HandleFunc("PATCH /api/drones/{id}", s.requireUser(s.handleUpdateDrone))
|
||||
mux.HandleFunc("DELETE /api/drones/{id}", s.requireUser(s.handleDeleteDrone))
|
||||
mux.HandleFunc("GET /api/flights", s.requireUser(s.handleListFlights))
|
||||
mux.HandleFunc("POST /api/flights", s.requireUser(s.handleCreateFlight))
|
||||
mux.HandleFunc("PATCH /api/flights/{id}", s.requireUser(s.handleUpdateFlight))
|
||||
mux.HandleFunc("DELETE /api/flights/{id}", s.requireUser(s.handleDeleteFlight))
|
||||
mux.HandleFunc("GET /api/logbook/export", s.requireUser(s.handleExportLogbook))
|
||||
|
||||
// Device / dashboard API.
|
||||
mux.HandleFunc("GET /api/devices", s.handleListDevices)
|
||||
mux.HandleFunc("GET /api/devices/{id}/track", s.handleTrack)
|
||||
|
||||
Reference in New Issue
Block a user