Files
PilotVault/API Server/internal/api/logbook_export.go
T
tajniak81andClaude Opus 4.8 e9b27530ec 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>
2026-07-13 13:11:39 +02:00

92 lines
2.6 KiB
Go

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"
}