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:
tajniak81
2026-07-13 13:11:39 +02:00
co-authored by Claude Opus 4.8
parent 407e34bf0d
commit e9b27530ec
14 changed files with 1843 additions and 23 deletions
+817
View File
@@ -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)
}
+91
View File
@@ -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"
}
+13
View File
@@ -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)