Files
PilotVault/API Server/pocketbase/pb_migrations/1720300700_add_logbook.js
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

164 lines
6.4 KiB
JavaScript

/// <reference path="../pb_data/types.d.ts" />
// Creates the drone-pilot logbook collections: `drones` (the airframes an
// operator flies, carrying the classification inputs that drive exemption /
// logging-path logic) and `flights` (the logbook entries themselves, modelled on
// Denmark's BEK nr. 1649 af 12/12/2023 "Dronebekendtgørelsen" § 5).
//
// Both collections are reached only through the API Server's superuser service
// account (like `organizations` + user management), so their API rules are left
// locked (superusers only); the API Server enforces per-role scoping in Go.
//
// Apply by copying into your PocketBase deployment's `pb_migrations/` directory
// and restarting. Written for PocketBase v0.22+/v0.23. Idempotent: each
// collection is created only if absent, so re-running is a no-op.
//
// Depends on 1720300200_add_organizations.js (organizations) and the `users`
// auth collection.
migrate(
(app) => {
const orgs = app.findCollectionByNameOrId('organizations')
const users = app.findCollectionByNameOrId('users')
// ---- drones -----------------------------------------------------------
let drones
try {
drones = app.findCollectionByNameOrId('drones')
} catch (_) {
drones = new Collection({
type: 'base',
name: 'drones',
fields: [
{ name: 'name', type: 'text', required: true, max: 120, presentable: true },
{ name: 'model', type: 'text', max: 120 },
{ name: 'serial', type: 'text', max: 120 },
// Trafikstyrelsen operator number displayed on the drone.
{ name: 'operator_number', type: 'text', max: 60 },
// Max take-off mass in grams — drives the < 250 g exemption.
{ name: 'mtom_grams', type: 'number', min: 0 },
{ name: 'is_toy', type: 'bool' },
// Has an onboard flight-data recorder (automatic-logging path).
{ name: 'autologs_flights', type: 'bool' },
// C-class marking: C0..C6 (or blank for legacy/unmarked).
{ name: 'c_class', type: 'select', maxSelect: 1, values: ['C0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6'] },
{
name: 'organization',
type: 'relation',
required: false,
collectionId: orgs.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
presentable: false,
},
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
],
indexes: [
'CREATE INDEX `idx_drones_org` ON `drones` (`organization`)',
],
})
app.save(drones)
drones = app.findCollectionByNameOrId('drones')
}
// ---- flights ----------------------------------------------------------
try {
app.findCollectionByNameOrId('flights')
return // already present
} catch (_) {
// create below
}
const flights = new Collection({
type: 'base',
name: 'flights',
fields: [
// -- BEK 1649 § 5 minimum content --
{ name: 'operation_date', type: 'date', required: true },
{ name: 'start_time', type: 'text', max: 5 }, // "HH:MM"
{ name: 'end_time', type: 'text', max: 5 }, // "HH:MM"
{
name: 'drone',
type: 'relation',
required: true,
collectionId: drones.id,
cascadeDelete: false,
minSelect: 1,
maxSelect: 1,
presentable: true,
},
// Area flown or route taken (free text; optional GeoJSON alongside).
{ name: 'area_route', type: 'text', max: 500 },
{ name: 'route_geojson', type: 'json', maxSize: 200000 },
// Maximum altitude relative to terrain, in metres AGL.
{ name: 'max_altitude_agl', type: 'number', min: 0 },
{
name: 'remote_pilot',
type: 'relation',
required: true,
collectionId: users.id,
cascadeDelete: false,
minSelect: 1,
maxSelect: 1,
presentable: false,
},
// Denormalised pilot name — § 5 requires the remote pilot's *name*, which
// the users relation alone may not carry.
{ name: 'pilot_name', type: 'text', max: 160 },
{ name: 'certificate_ref', type: 'text', max: 120 },
// -- category / logging path --
{ name: 'category', type: 'select', maxSelect: 1, values: ['open', 'specific', 'certified'] },
// Declared flight purpose — drives the exemption computation.
{ name: 'purpose', type: 'select', maxSelect: 1, values: ['hobby', 'commercial', 'research', 'public', 'club_area'] },
{ name: 'logging_path', type: 'select', maxSelect: 1, values: ['automatic', 'manual'] },
// Link to the stored FDR export (automatic path).
{ name: 'raw_fdr_log_url', type: 'text', max: 500 },
// Authorisation reference for Specific-category ops (STS/PDRA/SORA).
{ name: 'authorisation_ref', type: 'text', max: 200 },
// -- operational maturity (beyond the legal minimum) --
{ name: 'weather', type: 'text', max: 300 },
{ name: 'airspace_ref', type: 'text', max: 200 },
{ name: 'observer', type: 'text', max: 160 },
{ name: 'incidents', type: 'text', max: 1000 },
{ name: 'notes', type: 'text', max: 1000 },
// -- ownership + retention --
{
name: 'organization',
type: 'relation',
required: false,
collectionId: orgs.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
presentable: false,
},
// 5-year retention boundary — computed as operation_date + 5y at create.
{ name: 'retention_until', type: 'date' },
{ name: 'created', type: 'autodate', onCreate: true, onUpdate: false },
{ name: 'updated', type: 'autodate', onCreate: true, onUpdate: true },
],
indexes: [
'CREATE INDEX `idx_flights_pilot` ON `flights` (`remote_pilot`)',
'CREATE INDEX `idx_flights_org` ON `flights` (`organization`)',
'CREATE INDEX `idx_flights_date` ON `flights` (`operation_date`)',
],
})
app.save(flights)
},
(app) => {
// Down: remove flights first (it references drones), then drones.
for (const name of ['flights', 'drones']) {
try {
app.delete(app.findCollectionByNameOrId(name))
} catch (_) {
// already gone
}
}
},
)