Add compliance & operational document store (PocketBase)

Introduces a `documents` collection and full-stack UI for tracking pilot
certificates, aircraft registrations, insurance, airspace authorisations,
contracts, and other paperwork.

- Migration 1720300800_add_documents.js (also provisioned live on remote PB):
  doc_type/owner/expiry/status/access_tier + file blob, a self-referential
  `replaces` version chain, audit fields, and a partial index on expiry_date.
- API Server (documents.go): role-scoped CRUD, server-computed expiry
  assessment, ?expiring=N query, versioning (replaces -> version+1, old row
  auto-archived), and streamed blob download. admin.go gains multipart upload,
  file tokens, and protected-file streaming.
- Web App: BFF passthrough (multipart create + streamed download), api.js
  client fns, and Documents.vue wired into the Documents nav slot.

Blobs live in PocketBase file storage for now; only that backend swaps when
S3-compatible object storage lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-13 19:46:18 +02:00
co-authored by Claude Opus 4.8
parent e3106d7b60
commit 52f84ad6cf
14 changed files with 1481 additions and 23 deletions
+104
View File
@@ -6,7 +6,9 @@ import (
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"net/url"
"sync"
"time"
)
@@ -153,3 +155,105 @@ func (a *adminClient) do(ctx context.Context, method, path string, payload any)
}
return data, status, nil
}
// multipartFile is one file part for a multipart upload.
type multipartFile struct {
field string
filename string
data []byte
}
// doMultipart performs an admin request with a multipart/form-data body (used to
// upload PocketBase file-field records). It mirrors do()'s self-healing re-auth:
// on a 401 it re-authenticates once and retries. The body is buffered so the
// retry can resend it.
func (a *adminClient) doMultipart(ctx context.Context, method, path string, fields map[string]string, files []multipartFile) ([]byte, int, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for k, v := range fields {
if err := mw.WriteField(k, v); err != nil {
return nil, 0, err
}
}
for _, f := range files {
fw, err := mw.CreateFormFile(f.field, f.filename)
if err != nil {
return nil, 0, err
}
if _, err := fw.Write(f.data); err != nil {
return nil, 0, err
}
}
if err := mw.Close(); err != nil {
return nil, 0, err
}
contentType := mw.FormDataContentType()
body := buf.Bytes()
token := a.cachedToken()
if token == "" {
var err error
if token, err = a.authenticate(ctx); err != nil {
return nil, 0, err
}
}
baseURL, _, _ := a.creds()
send := func(tok string) ([]byte, int, error) {
req, _ := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
req.Header.Set("Authorization", tok)
req.Header.Set("Content-Type", contentType)
resp, err := a.client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
return data, resp.StatusCode, nil
}
data, status, err := send(token)
if err != nil {
return nil, 0, err
}
if status == http.StatusUnauthorized {
if token, err = a.authenticate(ctx); err != nil {
return nil, 0, err
}
return send(token)
}
return data, status, nil
}
// fileToken mints a short-lived PocketBase file-access token so protected files
// (the documents collection's rules are locked) can be fetched by URL.
func (a *adminClient) fileToken(ctx context.Context) (string, error) {
data, status, err := a.do(ctx, http.MethodPost, "/api/files/token", nil)
if err != nil {
return "", err
}
if status != http.StatusOK {
return "", errors.New("file token request failed: " + string(data))
}
var out struct {
Token string `json:"token"`
}
if err := json.Unmarshal(data, &out); err != nil || out.Token == "" {
return "", errors.New("file token: no token")
}
return out.Token, nil
}
// streamFile fetches a stored file for a record and returns the raw upstream
// response so the caller can copy its headers + body to the client. The caller
// must Close the returned Body.
func (a *adminClient) streamFile(ctx context.Context, collection, recordID, filename string) (*http.Response, error) {
tok, err := a.fileToken(ctx)
if err != nil {
return nil, err
}
baseURL, _, _ := a.creds()
fileURL := baseURL + "/api/files/" + url.PathEscape(collection) + "/" +
url.PathEscape(recordID) + "/" + url.PathEscape(filename) +
"?token=" + url.QueryEscape(tok)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
return a.client.Do(req)
}
+572
View File
@@ -0,0 +1,572 @@
package api
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// The document store holds PilotVault's compliance + operational paperwork —
// pilot certificates, aircraft registrations, insurance, airspace
// authorisations, contracts, and so on. Metadata is stored in the `documents`
// PocketBase collection; the blob itself is kept in PocketBase's file storage
// *for now* (the temporary stand-in for the S3-compatible object store this
// grows into). Like the logbook, every access flows through the superuser
// service account and per-role scoping is enforced here in Go.
//
// Scoping:
// - user → documents they uploaded, or that are about them (owner_pilot).
// - admin → all documents in their organization.
// - superadmin → everything.
//
// The highest-value behaviour is expiry alerting: each document carries an
// `expiry_date`, and the store computes a live expiry assessment (and a
// dedicated "expiring soon" query) so certs and registrations get flagged before
// they lapse.
// soonDays is the default window (days) within which an upcoming expiry is
// surfaced as "expiring soon".
const soonDays = 30
// ---------------------------------------------------------------------------
// Record shapes (snake_case, as stored) + client-facing views.
// ---------------------------------------------------------------------------
type documentRecord struct {
ID string `json:"id"`
Title string `json:"title"`
DocType string `json:"doc_type"`
OwnerType string `json:"owner_type"`
OwnerPilot string `json:"owner_pilot"`
OwnerDrone string `json:"owner_drone"`
OwnerRef string `json:"owner_ref"`
Reference string `json:"reference"`
Jurisdiction string `json:"jurisdiction"`
IssueDate string `json:"issue_date"`
ExpiryDate string `json:"expiry_date"`
Status string `json:"status"`
AccessTier string `json:"access_tier"`
File string `json:"file"`
Metadata json.RawMessage `json:"metadata"`
Notes string `json:"notes"`
Replaces string `json:"replaces"`
Version float64 `json:"version"`
UploadedBy string `json:"uploaded_by"`
Organization string `json:"organization"`
Created string `json:"created"`
Updated string `json:"updated"`
}
type documentView struct {
ID string `json:"id"`
Title string `json:"title"`
DocType string `json:"docType"`
OwnerType string `json:"ownerType"`
OwnerPilot string `json:"ownerPilot"`
OwnerDrone string `json:"ownerDrone"`
OwnerDroneName string `json:"ownerDroneName"`
OwnerRef string `json:"ownerRef"`
Reference string `json:"reference"`
Jurisdiction string `json:"jurisdiction"`
IssueDate string `json:"issueDate"`
ExpiryDate string `json:"expiryDate"`
Status string `json:"status"`
AccessTier string `json:"accessTier"`
FileName string `json:"fileName"`
HasFile bool `json:"hasFile"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Notes string `json:"notes"`
Replaces string `json:"replaces"`
Version int `json:"version"`
UploadedBy string `json:"uploadedBy"`
Organization string `json:"organization"`
Created string `json:"created"`
Updated string `json:"updated"`
Expiry expiryAssessment `json:"expiry"`
}
// expiryAssessment is the server-computed lifecycle assessment for a document.
type expiryAssessment struct {
State string `json:"state"` // no_expiry | valid | expiring_soon | expired
Days *int `json:"daysUntilExpiry"` // nil when no expiry date
Flags []string `json:"flags"` // things worth surfacing
}
func (d documentRecord) view(drones map[string]droneRecord, now time.Time) documentView {
droneName := ""
if d.OwnerDrone != "" {
if dr, ok := drones[d.OwnerDrone]; ok {
droneName = dr.Name
}
}
return documentView{
ID: d.ID, Title: d.Title, DocType: d.DocType, OwnerType: d.OwnerType,
OwnerPilot: d.OwnerPilot, OwnerDrone: d.OwnerDrone, OwnerDroneName: droneName,
OwnerRef: d.OwnerRef, Reference: d.Reference, Jurisdiction: d.Jurisdiction,
IssueDate: day(d.IssueDate), ExpiryDate: day(d.ExpiryDate), Status: d.Status,
AccessTier: d.AccessTier, FileName: d.File, HasFile: d.File != "",
Metadata: d.Metadata, Notes: d.Notes, Replaces: d.Replaces,
Version: int(d.Version), UploadedBy: d.UploadedBy, Organization: d.Organization,
Created: d.Created, Updated: d.Updated,
Expiry: computeExpiry(d, now),
}
}
// computeExpiry classifies a document by its expiry date relative to `now`.
func computeExpiry(d documentRecord, now time.Time) expiryAssessment {
a := expiryAssessment{State: "no_expiry", Flags: []string{}}
exp := parseDay(d.ExpiryDate)
if exp.IsZero() {
if d.Status == "pending_review" {
a.Flags = append(a.Flags, "Pending review — not yet confirmed valid")
}
return a
}
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
days := int(exp.Sub(today).Hours() / 24)
a.Days = &days
switch {
case days < 0:
a.State = "expired"
a.Flags = append(a.Flags, "Expired "+plural(-days, "day")+" ago — renew before the linked pilot/aircraft operates")
case days <= soonDays:
a.State = "expiring_soon"
a.Flags = append(a.Flags, "Expires in "+plural(days, "day")+" — schedule a renewal")
default:
a.State = "valid"
}
if d.Status == "pending_review" {
a.Flags = append(a.Flags, "Pending review — not yet confirmed valid")
}
return a
}
func plural(n int, unit string) string {
s := strconv.Itoa(n) + " " + unit
if n != 1 {
s += "s"
}
return s
}
// ---------------------------------------------------------------------------
// Scope + authorisation.
// ---------------------------------------------------------------------------
func documentScopeFilter(who *callerIdentity) string {
if who.isSuperadmin() {
return ""
}
if who.isManager() && who.OrgID != "" {
return "organization = \"" + who.OrgID + "\""
}
// Plain user: their own uploads, or documents about them.
return "uploaded_by = \"" + who.ID + "\" || owner_pilot = \"" + who.ID + "\""
}
func canManageDocument(who *callerIdentity, d documentRecord) bool {
if who.isSuperadmin() {
return true
}
if who.isManager() && who.OrgID != "" && d.Organization == who.OrgID {
return true
}
return d.UploadedBy == who.ID
}
func canViewDocument(who *callerIdentity, d documentRecord) bool {
if canManageDocument(who, d) {
return true
}
return d.OwnerPilot == who.ID
}
// getDocument fetches one document record by id.
func (s *Server) getDocument(ctx context.Context, id string) (documentRecord, int, error) {
var d documentRecord
data, status, err := s.admin.do(ctx, http.MethodGet,
"/api/collections/documents/records/"+url.PathEscape(id), nil)
if err != nil {
return d, 0, err
}
if status == http.StatusOK {
_ = json.Unmarshal(data, &d)
}
return d, status, nil
}
// ---------------------------------------------------------------------------
// List.
// ---------------------------------------------------------------------------
// GET /api/documents — list the caller's in-scope documents, each with its
// computed expiry assessment. Optional `?expiring=<days>` narrows the result to
// live documents that expire within that many days (already-expired included).
func (s *Server) handleListDocuments(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 []documentRecord `json:"items"`
}
if _, err := s.listRecords(r.Context(), "documents", documentScopeFilter(who),
"-created", &list); err != nil {
gatewayError(w, err)
return
}
within, expiringOnly := 0, false
if q := strings.TrimSpace(r.URL.Query().Get("expiring")); q != "" {
if n, err := strconv.Atoi(q); err == nil {
within, expiringOnly = n, true
}
}
now := time.Now().UTC()
out := make([]documentView, 0, len(list.Items))
for _, d := range list.Items {
v := d.view(drones, now)
if expiringOnly {
if d.Status == "archived" || v.Expiry.Days == nil || *v.Expiry.Days > within {
continue
}
}
out = append(out, v)
}
writeJSON(w, http.StatusOK, map[string]any{"documents": out})
}
// ---------------------------------------------------------------------------
// Create (multipart: metadata fields + an optional file blob).
// ---------------------------------------------------------------------------
const maxUploadBytes = 52 << 20 // 52 MiB, matching the collection's file cap.
// POST /api/documents — create a document. Accepts multipart/form-data so the
// blob can ride along with the metadata. When `replaces` names an existing
// document, this becomes the next version and the superseded row is archived
// (the version chain is preserved, never overwritten).
func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
who := caller(r)
if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
writeError(w, http.StatusBadRequest, "invalid multipart form")
return
}
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
writeError(w, http.StatusBadRequest, "document title is required")
return
}
// Resolve + authorise an owning drone, if one was named.
ownerDrone := strings.TrimSpace(r.FormValue("ownerDrone"))
if ownerDrone != "" {
drone, status, err := s.getDrone(r.Context(), ownerDrone)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK || !droneVisibleTo(who, drone) {
writeError(w, http.StatusBadRequest, "selected aircraft is not in your scope")
return
}
}
// Versioning: if replacing, inherit version+1 from the superseded document.
version := 1
replaces := strings.TrimSpace(r.FormValue("replaces"))
var superseded *documentRecord
if replaces != "" {
old, status, err := s.getDocument(r.Context(), replaces)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK || !canManageDocument(who, old) {
writeError(w, http.StatusBadRequest, "the document being replaced is not in your scope")
return
}
version = int(old.Version) + 1
if version < 2 {
version = 2
}
superseded = &old
}
fields := map[string]string{
"title": title,
"doc_type": strings.TrimSpace(r.FormValue("docType")),
"owner_type": strings.TrimSpace(r.FormValue("ownerType")),
"owner_ref": strings.TrimSpace(r.FormValue("ownerRef")),
"reference": strings.TrimSpace(r.FormValue("reference")),
"jurisdiction": strings.TrimSpace(r.FormValue("jurisdiction")),
"issue_date": strings.TrimSpace(r.FormValue("issueDate")),
"expiry_date": strings.TrimSpace(r.FormValue("expiryDate")),
"status": statusOr(r.FormValue("status")),
"access_tier": strings.TrimSpace(r.FormValue("accessTier")),
"notes": strings.TrimSpace(r.FormValue("notes")),
"version": strconv.Itoa(version),
"uploaded_by": who.ID,
}
// Relations + selects are omitted when blank so PocketBase doesn't reject an
// empty relation id.
setIfNotEmpty(fields, "owner_pilot", r.FormValue("ownerPilot"))
setIfNotEmpty(fields, "owner_drone", ownerDrone)
setIfNotEmpty(fields, "replaces", replaces)
setIfNotEmpty(fields, "organization", who.OrgID)
if md := strings.TrimSpace(r.FormValue("metadata")); md != "" && json.Valid([]byte(md)) {
fields["metadata"] = md
}
var files []multipartFile
if f, hdr, err := r.FormFile("file"); err == nil {
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, maxUploadBytes+1))
if err != nil {
writeError(w, http.StatusBadRequest, "could not read the uploaded file")
return
}
if len(data) > maxUploadBytes {
writeError(w, http.StatusRequestEntityTooLarge, "file exceeds the 50 MB limit")
return
}
files = append(files, multipartFile{field: "file", filename: hdr.Filename, data: data})
}
data, status, err := s.admin.doMultipart(r.Context(), http.MethodPost,
"/api/collections/documents/records", fields, files)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
relayRaw(w, status, data)
return
}
var d documentRecord
_ = json.Unmarshal(data, &d)
// Archive the superseded version now that the successor exists.
if superseded != nil {
_, _, _ = s.admin.do(r.Context(), http.MethodPatch,
"/api/collections/documents/records/"+url.PathEscape(superseded.ID),
map[string]any{"status": "archived"})
}
drones, _ := s.dronesInScope(r.Context(), who)
writeJSON(w, http.StatusCreated, map[string]any{"document": d.view(drones, time.Now().UTC())})
}
// statusOr defaults a blank/invalid status to "active".
func statusOr(s string) string {
switch strings.TrimSpace(s) {
case "pending_review", "archived", "active":
return strings.TrimSpace(s)
default:
return "active"
}
}
func setIfNotEmpty(m map[string]string, key, val string) {
if v := strings.TrimSpace(val); v != "" {
m[key] = v
}
}
// ---------------------------------------------------------------------------
// Update (metadata only; a new blob is a new version via create+replaces).
// ---------------------------------------------------------------------------
type documentPatch struct {
Title *string `json:"title"`
DocType *string `json:"docType"`
OwnerType *string `json:"ownerType"`
OwnerPilot *string `json:"ownerPilot"`
OwnerDrone *string `json:"ownerDrone"`
OwnerRef *string `json:"ownerRef"`
Reference *string `json:"reference"`
Jurisdiction *string `json:"jurisdiction"`
IssueDate *string `json:"issueDate"`
ExpiryDate *string `json:"expiryDate"`
Status *string `json:"status"`
AccessTier *string `json:"accessTier"`
Notes *string `json:"notes"`
Metadata json.RawMessage `json:"metadata"`
}
// PATCH /api/documents/{id} — update a document's metadata.
func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
who := caller(r)
id := r.PathValue("id")
existing, status, err := s.getDocument(r.Context(), id)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusNotFound, "document not found")
return
}
if !canManageDocument(who, existing) {
writeError(w, http.StatusForbidden, "you cannot modify this document")
return
}
var in documentPatch
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
payload := map[string]any{}
putStr := func(key string, p *string) {
if p != nil {
payload[key] = strings.TrimSpace(*p)
}
}
if in.Title != nil {
t := strings.TrimSpace(*in.Title)
if t == "" {
writeError(w, http.StatusBadRequest, "document title cannot be empty")
return
}
payload["title"] = t
}
putStr("doc_type", in.DocType)
putStr("owner_type", in.OwnerType)
putStr("owner_ref", in.OwnerRef)
putStr("reference", in.Reference)
putStr("jurisdiction", in.Jurisdiction)
putStr("issue_date", in.IssueDate)
putStr("expiry_date", in.ExpiryDate)
putStr("access_tier", in.AccessTier)
putStr("notes", in.Notes)
// Relations may be cleared (empty string tells PocketBase to unset them).
if in.OwnerPilot != nil {
payload["owner_pilot"] = strings.TrimSpace(*in.OwnerPilot)
}
if in.OwnerDrone != nil {
payload["owner_drone"] = strings.TrimSpace(*in.OwnerDrone)
}
if in.Status != nil {
payload["status"] = statusOr(*in.Status)
}
if len(in.Metadata) > 0 && json.Valid(in.Metadata) {
payload["metadata"] = in.Metadata
}
data, status, err := s.admin.do(r.Context(), http.MethodPatch,
"/api/collections/documents/records/"+url.PathEscape(id), payload)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
relayRaw(w, status, data)
return
}
var d documentRecord
_ = json.Unmarshal(data, &d)
drones, _ := s.dronesInScope(r.Context(), who)
writeJSON(w, http.StatusOK, map[string]any{"document": d.view(drones, time.Now().UTC())})
}
// ---------------------------------------------------------------------------
// Delete.
// ---------------------------------------------------------------------------
// DELETE /api/documents/{id} — delete a document (and its stored blob).
func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
who := caller(r)
id := r.PathValue("id")
existing, status, err := s.getDocument(r.Context(), id)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusNotFound, "document not found")
return
}
if !canManageDocument(who, existing) {
writeError(w, http.StatusForbidden, "you cannot delete this document")
return
}
_, st, err := s.admin.do(r.Context(), http.MethodDelete,
"/api/collections/documents/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 document")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// ---------------------------------------------------------------------------
// File download (streamed through the API Server so the browser never touches
// PocketBase directly).
// ---------------------------------------------------------------------------
// GET /api/documents/{id}/file — stream the stored blob for a document.
func (s *Server) handleDownloadDocument(w http.ResponseWriter, r *http.Request) {
who := caller(r)
id := r.PathValue("id")
rec, status, err := s.getDocument(r.Context(), id)
if err != nil {
gatewayError(w, err)
return
}
if status != http.StatusOK {
writeError(w, http.StatusNotFound, "document not found")
return
}
if !canViewDocument(who, rec) {
writeError(w, http.StatusForbidden, "you cannot access this document")
return
}
if rec.File == "" {
writeError(w, http.StatusNotFound, "this document has no file attached")
return
}
resp, err := s.admin.streamFile(r.Context(), "documents", rec.ID, rec.File)
if err != nil {
gatewayError(w, err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
writeError(w, http.StatusBadGateway, "could not retrieve the stored file")
return
}
if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
}
if cl := resp.Header.Get("Content-Length"); cl != "" {
w.Header().Set("Content-Length", cl)
}
w.Header().Set("Content-Disposition", "attachment; filename=\""+sanitizeFilename(rec.File)+"\"")
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, resp.Body)
}
// sanitizeFilename strips characters that would break a Content-Disposition
// header (quotes / control chars); PocketBase filenames are already safe, this
// is belt-and-braces.
func sanitizeFilename(name string) string {
return strings.Map(func(r rune) rune {
if r == '"' || r < 0x20 {
return '_'
}
return r
}, name)
}
+9
View File
@@ -153,6 +153,15 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("DELETE /api/flights/{id}", s.requireUser(s.handleDeleteFlight))
mux.HandleFunc("GET /api/logbook/export", s.requireUser(s.handleExportLogbook))
// Documents — the compliance + operational document store (metadata in
// PocketBase, blob in PocketBase file storage for now). Available to any
// authenticated user; per-role scoping is enforced inside the handlers.
mux.HandleFunc("GET /api/documents", s.requireUser(s.handleListDocuments))
mux.HandleFunc("POST /api/documents", s.requireUser(s.handleCreateDocument))
mux.HandleFunc("PATCH /api/documents/{id}", s.requireUser(s.handleUpdateDocument))
mux.HandleFunc("DELETE /api/documents/{id}", s.requireUser(s.handleDeleteDocument))
mux.HandleFunc("GET /api/documents/{id}/file", s.requireUser(s.handleDownloadDocument))
// Device / dashboard API.
mux.HandleFunc("GET /api/devices", s.handleListDevices)
mux.HandleFunc("GET /api/devices/{id}/track", s.handleTrack)
@@ -0,0 +1,171 @@
/// <reference path="../pb_data/types.d.ts" />
// Creates the `documents` collection: the compliance + operational document
// store for PilotVault (pilot certificates, aircraft registrations, insurance,
// airspace authorisations, contracts, …). Metadata lives here; the blob lives in
// PocketBase's own file storage *for now* — the file field is the temporary
// stand-in for the S3-compatible object store this will grow into. When that
// lands, only the blob backend changes: this metadata shape (and its
// `replaces` version chain + `expiry_date`-driven alerting) stays.
//
// Like `organizations`, user management, and the logbook, the collection is
// reached only through the API Server's superuser service account, so its API
// rules stay 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: the
// collection is created only if absent, so re-running is a no-op.
//
// Depends on 1720300200_add_organizations.js (organizations), the `users` auth
// collection, and 1720300700_add_logbook.js (drones — a document may be owned by
// a specific airframe).
migrate(
(app) => {
// Idempotency guard.
try {
app.findCollectionByNameOrId('documents')
return // already present
} catch (_) {
// create below
}
const orgs = app.findCollectionByNameOrId('organizations')
const users = app.findCollectionByNameOrId('users')
const drones = app.findCollectionByNameOrId('drones')
const documents = new Collection({
type: 'base',
name: 'documents',
fields: [
{ name: 'title', type: 'text', required: true, max: 200, presentable: true },
// What kind of paperwork this is — drives filtering + which owner makes
// sense. Kept broad to cover pilot / aircraft / operational / business.
{
name: 'doc_type',
type: 'select',
maxSelect: 1,
values: [
'certificate', 'medical', 'insurance', 'background_check',
'registration', 'maintenance', 'conformity', 'firmware', 'incident',
'flight_log', 'checklist', 'airspace_auth', 'mission_plan',
'risk_assessment', 'contract', 'client_insurance', 'delivery_report',
'other',
],
},
// -- ownership: who/what the document is about --
{ name: 'owner_type', type: 'select', maxSelect: 1, values: ['pilot', 'aircraft', 'organization', 'client', 'other'] },
{
name: 'owner_pilot',
type: 'relation',
required: false,
collectionId: users.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
presentable: false,
},
{
name: 'owner_drone',
type: 'relation',
required: false,
collectionId: drones.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
presentable: false,
},
// Free-form owner reference for client/other (client name, aircraft
// serial, site…), used when no relation fits.
{ name: 'owner_ref', type: 'text', max: 200 },
// -- identity + compliance drivers --
// Certificate / registration / policy number.
{ name: 'reference', type: 'text', max: 200 },
{ name: 'jurisdiction', type: 'text', max: 120 },
{ name: 'issue_date', type: 'date' },
// The single highest-value field: drives expiry alerting (expired certs =
// grounded fleet). Blank = the document never expires.
{ name: 'expiry_date', type: 'date' },
// Lifecycle. `archived` marks a row superseded by a newer version (see
// `replaces`) — the chain is kept, nothing is overwritten in place.
{ name: 'status', type: 'select', maxSelect: 1, values: ['active', 'pending_review', 'archived'] },
// Who may view — informational for now; scope is enforced by org/role.
{ name: 'access_tier', type: 'select', maxSelect: 1, values: ['pilot', 'ops', 'admin', 'client'] },
// The blob itself (temporary PocketBase-hosted stand-in for object
// storage). Single file, ~50 MB cap.
{ name: 'file', type: 'file', maxSelect: 1, maxSize: 52428800 },
// Type-specific fields that shouldn't need a schema migration each — same
// JSONB-style escape hatch used for plugin config.
{ name: 'metadata', type: 'json', maxSize: 50000 },
{ name: 'notes', type: 'text', max: 2000 },
// -- versioning (replaces_id chain) + audit --
{
name: 'replaces',
type: 'relation',
required: false,
collectionId: '', // self-reference; patched to documents.id after save
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
presentable: false,
},
{ name: 'version', type: 'number', min: 1 },
{
name: 'uploaded_by',
type: 'relation',
required: false,
collectionId: users.id,
cascadeDelete: false,
minSelect: 0,
maxSelect: 1,
presentable: false,
},
{
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_documents_org` ON `documents` (`organization`)',
'CREATE INDEX `idx_documents_owner_pilot` ON `documents` (`owner_pilot`)',
'CREATE INDEX `idx_documents_owner_drone` ON `documents` (`owner_drone`)',
// Partial index: only the live rows the expiry job scans, keeping it
// cheap as archived/superseded versions accumulate.
"CREATE INDEX `idx_documents_expiring` ON `documents` (`expiry_date`) WHERE `status` = 'active'",
],
})
app.save(documents)
// Point the self-referential `replaces` relation at the now-created
// collection (its id wasn't known before the first save).
const saved = app.findCollectionByNameOrId('documents')
const replaces = saved.fields.find((f) => f.name === 'replaces')
if (replaces) {
replaces.collectionId = saved.id
app.save(saved)
}
},
(app) => {
try {
app.delete(app.findCollectionByNameOrId('documents'))
} catch (_) {
// already gone
}
},
)
+64
View File
@@ -465,6 +465,70 @@ func (a *App) handleExportLogbook(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, resp.Body)
}
/* ---------- Documents ---------- */
// GET /bff/documents → API Server /api/documents (preserves ?expiring=N).
func (a *App) handleListDocuments(w http.ResponseWriter, r *http.Request) {
target := a.apiBaseFor(r) + "/api/documents"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
req, _ := http.NewRequest(http.MethodGet, target, nil)
req.Header.Set("Authorization", tokenOf(r))
a.doRelay(w, req)
}
// POST /bff/documents → API Server /api/documents. Streams the multipart body
// through unchanged (metadata fields + the optional file blob).
func (a *App) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/documents", r.Body)
req.Header.Set("Authorization", tokenOf(r))
if ct := r.Header.Get("Content-Type"); ct != "" {
req.Header.Set("Content-Type", ct)
}
req.ContentLength = r.ContentLength
a.doRelay(w, req)
}
// PATCH /bff/documents/{id} → API Server /api/documents/{id} (JSON metadata).
func (a *App) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
body, _ := io.ReadAll(r.Body)
req, _ := http.NewRequest(http.MethodPatch, a.apiBaseFor(r)+"/api/documents/"+url.PathEscape(id), bytes.NewReader(body))
req.Header.Set("Authorization", tokenOf(r))
req.Header.Set("Content-Type", "application/json")
a.doRelay(w, req)
}
// DELETE /bff/documents/{id} → API Server /api/documents/{id}
func (a *App) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
req, _ := http.NewRequest(http.MethodDelete, a.apiBaseFor(r)+"/api/documents/"+url.PathEscape(id), nil)
req.Header.Set("Authorization", tokenOf(r))
a.doRelay(w, req)
}
// GET /bff/documents/{id}/file → API Server /api/documents/{id}/file. Streams
// the blob download, preserving the upstream Content-Type + Content-Disposition.
func (a *App) handleDownloadDocument(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
req, _ := http.NewRequest(http.MethodGet, a.apiBaseFor(r)+"/api/documents/"+url.PathEscape(id)+"/file", nil)
req.Header.Set("Authorization", tokenOf(r))
resp, err := client.Do(req)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "cannot reach API server"})
return
}
defer resp.Body.Close()
for _, h := range []string{"Content-Type", "Content-Disposition", "Content-Length"} {
if v := resp.Header.Get(h); v != "" {
w.Header().Set(h, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}
// doRelay executes an outbound request and relays the response verbatim.
func (a *App) doRelay(w http.ResponseWriter, req *http.Request) {
resp, err := client.Do(req)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -35,8 +35,8 @@
})()
</script>
<title>PilotVault — Control Panel</title>
<script type="module" crossorigin src="./assets/index-DLbqB6QP.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Co-T5CTN.css">
<script type="module" crossorigin src="./assets/index-Bw9Fgeki.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B-aj3yVr.css">
</head>
<body>
<div id="app"></div>
+6
View File
@@ -82,6 +82,12 @@ func main() {
mux.HandleFunc("PATCH /bff/flights/{id}", app.requireAuth(app.handleUpdateFlight))
mux.HandleFunc("DELETE /bff/flights/{id}", app.requireAuth(app.handleDeleteFlight))
mux.HandleFunc("GET /bff/logbook/export", app.requireAuth(app.handleExportLogbook))
// Documents — the compliance + operational document store (scoping upstream)
mux.HandleFunc("GET /bff/documents", app.requireAuth(app.handleListDocuments))
mux.HandleFunc("POST /bff/documents", app.requireAuth(app.handleCreateDocument))
mux.HandleFunc("PATCH /bff/documents/{id}", app.requireAuth(app.handleUpdateDocument))
mux.HandleFunc("DELETE /bff/documents/{id}", app.requireAuth(app.handleDeleteDocument))
mux.HandleFunc("GET /bff/documents/{id}/file", app.requireAuth(app.handleDownloadDocument))
mux.HandleFunc("GET /bff/ws", app.handleWS)
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "api": app.apiBase})
+48
View File
@@ -330,6 +330,54 @@ export function exportLogbookUrl() {
return '/bff/logbook/export'
}
/* ---------- Documents ---------- */
// List in-scope documents. Pass `expiring` (days) to narrow to documents that
// expire within that window (already-expired included).
export async function getDocuments(expiring) {
try {
const qs = expiring != null && expiring !== '' ? `?expiring=${encodeURIComponent(expiring)}` : ''
const r = await fetch(`/bff/documents${qs}`)
if (!r.ok) return { ok: false, status: r.status, documents: [] }
const d = await r.json()
return { ok: true, status: 200, documents: d.documents || [] }
} catch {
return { ok: false, status: 0, documents: [] }
}
}
// Create a document. `fields` is a plain object of metadata; `file` is an
// optional File (from an <input type=file>). Sent as multipart/form-data so the
// blob rides along with the metadata.
export async function createDocument(fields, file) {
const fd = new FormData()
Object.entries(fields).forEach(([k, v]) => {
if (v != null && v !== '') fd.append(k, v)
})
if (file) fd.append('file', file)
const r = await fetch('/bff/documents', { method: 'POST', body: fd })
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
export async function updateDocument(id, changes) {
const r = await fetch(`/bff/documents/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
export async function deleteDocument(id) {
const r = await fetch(`/bff/documents/${encodeURIComponent(id)}`, { method: 'DELETE' })
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
// URL that streams a document's stored blob as a download.
export function documentFileUrl(id) {
return `/bff/documents/${encodeURIComponent(id)}/file`
}
export async function sendCommand(id, command, payload) {
const r = await fetch(`/bff/devices/${encodeURIComponent(id)}/command`, {
method: 'POST',
+4
View File
@@ -5,6 +5,7 @@ import BrandMark from './BrandMark.vue'
import Icon from './Icon.vue'
import Settings from './Settings.vue'
import Logbook from './Logbook.vue'
import Documents from './Documents.vue'
import { getDevices, sendCommand } from '../api.js'
import { formatTime } from '../prefs.js'
@@ -631,6 +632,9 @@ onBeforeUnmount(() => {
<!-- ---------- Logbook ---------- -->
<Logbook v-else-if="active === 'Logbook'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
<!-- ---------- Documents ---------- -->
<Documents v-else-if="active === 'Documents'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
<!-- ---------- Settings ---------- -->
<Settings v-else-if="active === 'Settings'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" @logout="emit('logout')" />
+480
View File
@@ -0,0 +1,480 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import Icon from './Icon.vue'
import {
getDocuments, createDocument, updateDocument, deleteDocument, documentFileUrl,
getDrones,
} from '../api.js'
const props = defineProps({
email: { type: String, default: '' },
role: { type: String, default: 'user' },
organization: { type: String, default: '' },
organizationName: { type: String, default: '' },
})
const badgeClass = {
success: 'bg-success-soft text-success-fg',
warning: 'bg-amber-soft text-amber-fg',
danger: 'bg-danger-soft text-danger-fg',
accent: 'bg-accent-soft text-accent-soft-fg',
neutral: 'bg-surface-2 text-ink-secondary',
}
const DOC_TYPES = [
{ value: 'certificate', label: 'Pilot certificate' },
{ value: 'medical', label: 'Medical / training' },
{ value: 'insurance', label: 'Insurance / liability' },
{ value: 'background_check', label: 'Background check / waiver' },
{ value: 'registration', label: 'Aircraft registration' },
{ value: 'maintenance', label: 'Maintenance log' },
{ value: 'conformity', label: 'Conformity / compliance' },
{ value: 'firmware', label: 'Firmware / software' },
{ value: 'incident', label: 'Incident / repair report' },
{ value: 'flight_log', label: 'Flight log' },
{ value: 'checklist', label: 'Pre-flight checklist' },
{ value: 'airspace_auth', label: 'Airspace authorisation' },
{ value: 'mission_plan', label: 'Mission plan / flight path' },
{ value: 'risk_assessment', label: 'Risk assessment / survey' },
{ value: 'contract', label: 'Contract / SOW' },
{ value: 'client_insurance', label: 'Client insurance cert' },
{ value: 'delivery_report', label: 'Delivery / media handoff' },
{ value: 'other', label: 'Other' },
]
const DOC_TYPE_LABEL = Object.fromEntries(DOC_TYPES.map((t) => [t.value, t.label]))
const OWNER_TYPES = [
{ value: 'pilot', label: 'Pilot' },
{ value: 'aircraft', label: 'Aircraft' },
{ value: 'organization', label: 'Organization' },
{ value: 'client', label: 'Client' },
{ value: 'other', label: 'Other' },
]
const STATUSES = [
{ value: 'active', label: 'Active' },
{ value: 'pending_review', label: 'Pending review' },
{ value: 'archived', label: 'Archived' },
]
const ACCESS_TIERS = [
{ value: 'pilot', label: 'Pilot' },
{ value: 'ops', label: 'Ops manager' },
{ value: 'admin', label: 'Admin' },
{ value: 'client', label: 'Client-facing' },
]
const documents = ref([])
const drones = ref([])
const loading = ref(false)
const loadErr = ref('')
async function loadAll() {
loading.value = true
loadErr.value = ''
const [dc, dr] = await Promise.all([getDocuments(), getDrones()])
if (!dc.ok) {
loadErr.value =
dc.status === 503
? 'Document storage is not configured on the API Server (service account missing).'
: 'Could not load documents.'
}
documents.value = dc.documents
drones.value = dr.drones || []
loading.value = false
}
onMounted(loadAll)
/* ---------------- filtering ---------------- */
const filter = ref('all') // all | expiring | expired | pending | archived
const FILTERS = [
['all', 'All'],
['expiring', 'Expiring soon'],
['expired', 'Expired'],
['pending', 'Pending review'],
['archived', 'Archived'],
]
const visible = computed(() => {
const all = documents.value
switch (filter.value) {
case 'expiring':
return all.filter((d) => d.expiry?.state === 'expiring_soon' && d.status !== 'archived')
case 'expired':
return all.filter((d) => d.expiry?.state === 'expired' && d.status !== 'archived')
case 'pending':
return all.filter((d) => d.status === 'pending_review')
case 'archived':
return all.filter((d) => d.status === 'archived')
default:
return all.filter((d) => d.status !== 'archived')
}
})
/* ---------------- expiry presentation ---------------- */
function expiryBadge(d) {
if (d.status === 'archived') return { tone: 'neutral', label: 'Superseded', icon: '' }
const e = d.expiry || {}
if (e.state === 'expired') return { tone: 'danger', label: 'Expired', icon: 'alertTriangle' }
if (e.state === 'expiring_soon')
return { tone: 'warning', label: `Expires in ${e.daysUntilExpiry}d`, icon: 'clock' }
if (e.state === 'valid') return { tone: 'success', label: 'Valid', icon: 'check' }
return { tone: 'neutral', label: 'No expiry', icon: '' }
}
const openRow = ref('')
function toggleRow(id) {
openRow.value = openRow.value === id ? '' : id
}
function ownerLabel(d) {
if (d.ownerDrone) return d.ownerDroneName || 'Aircraft'
if (d.ownerRef) return d.ownerRef
if (d.ownerType === 'pilot') return 'Pilot'
if (d.ownerType) return d.ownerType.charAt(0).toUpperCase() + d.ownerType.slice(1)
return '—'
}
/* ---------------- document form ---------------- */
function blankDoc() {
return {
title: '', docType: 'certificate', ownerType: 'pilot', ownerDrone: '',
ownerRef: '', reference: '', jurisdiction: '', issueDate: '', expiryDate: '',
status: 'active', accessTier: 'ops', notes: '',
}
}
const showForm = ref(false)
const editingId = ref('')
const replacesId = ref('') // set when uploading a new version of an existing doc
const replacesTitle = ref('')
const form = reactive(blankDoc())
const file = ref(null)
const fileInput = ref(null)
const formMsg = ref('')
const saving = ref(false)
function resetFileInput() {
file.value = null
if (fileInput.value) fileInput.value.value = ''
}
function newDoc() {
Object.assign(form, blankDoc())
editingId.value = ''
replacesId.value = ''
replacesTitle.value = ''
resetFileInput()
formMsg.value = ''
showForm.value = true
}
function editDoc(d) {
Object.assign(form, {
title: d.title || '', docType: d.docType || 'certificate',
ownerType: d.ownerType || 'pilot', ownerDrone: d.ownerDrone || '',
ownerRef: d.ownerRef || '', reference: d.reference || '',
jurisdiction: d.jurisdiction || '', issueDate: d.issueDate || '',
expiryDate: d.expiryDate || '', status: d.status || 'active',
accessTier: d.accessTier || 'ops', notes: d.notes || '',
})
editingId.value = d.id
replacesId.value = ''
replacesTitle.value = ''
resetFileInput()
formMsg.value = ''
showForm.value = true
}
// Upload a superseding version: pre-fill from the current doc, require a file.
function newVersion(d) {
editDoc(d)
editingId.value = ''
replacesId.value = d.id
replacesTitle.value = d.title
form.status = 'active'
}
function cancelForm() {
showForm.value = false
editingId.value = ''
replacesId.value = ''
}
function onFile(e) {
file.value = e.target.files?.[0] || null
}
async function save() {
formMsg.value = ''
if (!form.title.trim()) {
formMsg.value = 'Give the document a title.'
return
}
saving.value = true
let res
if (editingId.value) {
// Metadata-only update (a new blob is a new version, not an overwrite).
res = await updateDocument(editingId.value, { ...form })
} else {
const fields = { ...form }
if (replacesId.value) fields.replaces = replacesId.value
res = await createDocument(fields, file.value)
}
saving.value = false
if (!res.ok) {
formMsg.value = res.body?.error || 'Could not save the document.'
return
}
showForm.value = false
editingId.value = ''
replacesId.value = ''
await loadAll()
}
const confirmId = ref('')
async function removeDoc(d) {
const res = await deleteDocument(d.id)
confirmId.value = ''
if (res.ok) await loadAll()
else formMsg.value = res.body?.error || 'Could not delete the document.'
}
/* ---------------- headline stats ---------------- */
const stats = computed(() => {
const live = documents.value.filter((d) => d.status !== 'archived')
return {
total: live.length,
expiring: live.filter((d) => d.expiry?.state === 'expiring_soon').length,
expired: live.filter((d) => d.expiry?.state === 'expired').length,
pending: documents.value.filter((d) => d.status === 'pending_review').length,
}
})
</script>
<template>
<div class="mx-auto flex max-w-[1240px] flex-col gap-5 p-7">
<!-- header + actions -->
<div class="flex flex-wrap items-center gap-3">
<div class="inline-flex flex-wrap rounded-lg border border-line bg-surface-1 p-0.5">
<button
v-for="f in FILTERS"
:key="f[0]"
class="rounded-md px-3.5 py-1.5 text-sm font-semibold transition"
:class="filter === f[0] ? 'bg-accent-soft text-accent-soft-fg' : 'text-ink-secondary hover:text-ink'"
@click="filter = f[0]"
>
{{ f[1] }}
</button>
</div>
<div class="ml-auto">
<button class="btn-accent inline-flex items-center gap-2" @click="newDoc">
<Icon name="upload" :size="15" /> Add document
</button>
</div>
</div>
<!-- stat row -->
<div class="grid grid-cols-4 gap-4 max-[900px]:grid-cols-2">
<div v-for="s in [
{ label: 'Documents on file', value: stats.total, tone: 'neutral' },
{ label: 'Expiring soon', value: stats.expiring, tone: stats.expiring ? 'warning' : 'neutral' },
{ label: 'Expired', value: stats.expired, tone: stats.expired ? 'danger' : 'success' },
{ label: 'Pending review', value: stats.pending, tone: stats.pending ? 'accent' : 'neutral' },
]" :key="s.label" class="panel p-5">
<div class="eyebrow">{{ s.label }}</div>
<div class="mt-2 text-[30px] font-bold leading-none tracking-tightest"
:class="s.tone === 'danger' ? 'text-danger-fg' : s.tone === 'warning' ? 'text-amber-fg' : s.tone === 'success' ? 'text-success-fg' : s.tone === 'accent' ? 'text-accent-soft-fg' : 'text-ink'">
{{ s.value }}
</div>
</div>
</div>
<div v-if="loadErr" class="panel border-danger/40 p-4 text-sm text-danger-fg">{{ loadErr }}</div>
<!-- add / edit form -->
<div v-if="showForm" class="panel p-5">
<div class="mb-4 flex items-center justify-between">
<div>
<div class="eyebrow">
{{ editingId ? 'Edit document' : replacesId ? 'New version' : 'New document' }}
</div>
<div class="mt-0.5 text-base font-semibold text-ink">
{{ replacesId ? `Supersedes “${replacesTitle}”` : 'Compliance & operational document' }}
</div>
</div>
<button class="btn-icon" @click="cancelForm"><Icon name="x" :size="16" /></button>
</div>
<div class="grid grid-cols-3 gap-3 max-[760px]:grid-cols-1">
<label class="col-span-2 block max-[760px]:col-span-1">
<span class="eyebrow mb-1 block">Title</span>
<input v-model="form.title" class="field" placeholder="A2 Remote Pilot Certificate — J. Dariusz" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Type</span>
<select v-model="form.docType" class="field">
<option v-for="t in DOC_TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Owner type</span>
<select v-model="form.ownerType" class="field">
<option v-for="o in OWNER_TYPES" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Aircraft (if any)</span>
<select v-model="form.ownerDrone" class="field">
<option value="">— none —</option>
<option v-for="d in drones" :key="d.id" :value="d.id">{{ d.name }}{{ d.model ? ` · ${d.model}` : '' }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Owner reference</span>
<input v-model="form.ownerRef" class="field" placeholder="Client name / serial / site" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Reference / number</span>
<input v-model="form.reference" class="field" placeholder="Cert / registration / policy no." />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Jurisdiction</span>
<input v-model="form.jurisdiction" class="field" placeholder="DK / EASA / FAA" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Access tier</span>
<select v-model="form.accessTier" class="field">
<option v-for="a in ACCESS_TIERS" :key="a.value" :value="a.value">{{ a.label }}</option>
</select>
</label>
<label class="block">
<span class="eyebrow mb-1 block">Issue date</span>
<input v-model="form.issueDate" type="date" class="field" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Expiry date</span>
<input v-model="form.expiryDate" type="date" class="field" />
</label>
<label class="block">
<span class="eyebrow mb-1 block">Status</span>
<select v-model="form.status" class="field">
<option v-for="st in STATUSES" :key="st.value" :value="st.value">{{ st.label }}</option>
</select>
</label>
</div>
<label class="mt-3 block">
<span class="eyebrow mb-1 block">Notes</span>
<textarea v-model="form.notes" rows="2" class="field" placeholder="Conditions, renewal contacts, anything worth recording"></textarea>
</label>
<!-- file: only on create / new version; editing is metadata-only -->
<div v-if="!editingId" class="mt-3">
<span class="eyebrow mb-1 block">File {{ replacesId ? '(new version)' : '(optional)' }}</span>
<input ref="fileInput" type="file" class="field" @change="onFile" />
<p class="mt-1 text-xs text-ink-muted">
Stored in PocketBase for now (object storage later). Max 50 MB.
</p>
</div>
<div v-else class="mt-3 text-xs text-ink-muted">
Editing updates metadata only. To replace the file, close this and use
<b class="text-ink-secondary">New version</b> on the document — the old version is kept for audit.
</div>
<div class="mt-4 flex items-center gap-3">
<button class="btn-accent" :disabled="saving" @click="save">
{{ saving ? 'Saving…' : editingId ? 'Save changes' : replacesId ? 'Upload new version' : 'Add document' }}
</button>
<button class="btn-ghost" @click="cancelForm">Cancel</button>
<span v-if="formMsg" class="text-sm text-danger-fg">{{ formMsg }}</span>
</div>
</div>
<!-- documents table -->
<div class="panel overflow-hidden p-0">
<div v-if="loading" class="px-5 py-12 text-center text-sm text-ink-muted">Loading…</div>
<div v-else-if="!visible.length" class="grid place-items-center px-5 py-16 text-center">
<Icon name="fileText" :size="26" class="text-ink-muted" />
<div class="mt-3 text-sm font-medium text-ink-secondary">
{{ filter === 'all' ? 'No documents on file yet' : 'Nothing in this view' }}
</div>
<div class="mt-1 text-xs text-ink-muted">
{{ filter === 'all'
? 'Add certificates, registrations, insurance and authorisations to track their expiry.'
: 'Try a different filter.' }}
</div>
</div>
<div v-else class="overflow-x-auto">
<table class="w-full border-collapse text-sm">
<thead>
<tr class="text-left">
<th v-for="h in ['Title', 'Type', 'Owner', 'Expiry', 'Ver', '']" :key="h"
class="border-y border-line bg-surface-2 px-5 py-2.5 font-mono text-[10.5px] font-normal uppercase tracking-caps text-ink-muted">
{{ h }}
</th>
</tr>
</thead>
<tbody>
<template v-for="d in visible" :key="d.id">
<tr class="border-b border-line last:border-0" :class="editingId === d.id ? 'bg-accent-soft' : ''">
<td class="px-5 py-3">
<div class="font-semibold text-ink">{{ d.title }}</div>
<div v-if="d.reference" class="font-mono text-[11px] text-ink-muted">{{ d.reference }}</div>
</td>
<td class="px-5 py-3 text-ink-secondary">{{ DOC_TYPE_LABEL[d.docType] || d.docType || '—' }}</td>
<td class="px-5 py-3 text-ink-secondary">{{ ownerLabel(d) }}</td>
<td class="px-5 py-3">
<button
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="badgeClass[expiryBadge(d).tone]"
@click="toggleRow(d.id)"
>
<Icon v-if="expiryBadge(d).icon" :name="expiryBadge(d).icon" :size="12" />
{{ expiryBadge(d).label }}
</button>
<div v-if="d.expiryDate" class="mt-0.5 font-mono text-[10.5px] text-ink-muted">{{ d.expiryDate }}</div>
</td>
<td class="px-5 py-3 font-mono text-ink-secondary">v{{ d.version || 1 }}</td>
<td class="whitespace-nowrap px-5 py-3 text-right">
<template v-if="confirmId === d.id">
<span class="mr-2 text-xs text-ink-muted">Delete?</span>
<button class="btn-ghost mr-1" @click="confirmId = ''">Cancel</button>
<button class="rounded bg-danger px-3 py-1.5 text-sm font-semibold text-white transition hover:brightness-110" @click="removeDoc(d)">Delete</button>
</template>
<template v-else>
<a v-if="d.hasFile" :href="documentFileUrl(d.id)" class="btn-ghost mr-1 inline-flex items-center gap-1" title="Download file">
<Icon name="download" :size="13" />
</a>
<button class="btn-ghost mr-1 inline-flex items-center gap-1" title="Upload new version" @click="newVersion(d)"><Icon name="upload" :size="13" /></button>
<button class="btn-ghost mr-1 inline-flex items-center gap-1" @click="editDoc(d)"><Icon name="sliders" :size="13" /> Edit</button>
<button class="btn-ghost inline-flex items-center gap-1" @click="confirmId = d.id"><Icon name="trash" :size="13" /></button>
</template>
</td>
</tr>
<tr v-if="openRow === d.id" class="border-b border-line bg-surface-2">
<td colspan="6" class="px-5 py-3">
<div class="flex flex-wrap gap-x-8 gap-y-1.5 text-xs">
<span class="text-ink-secondary">Status: <b class="text-ink">{{ d.status || '—' }}</b></span>
<span class="text-ink-secondary">Access: <b class="text-ink">{{ d.accessTier || '—' }}</b></span>
<span v-if="d.jurisdiction" class="text-ink-secondary">Jurisdiction: <b class="text-ink">{{ d.jurisdiction }}</b></span>
<span v-if="d.issueDate" class="text-ink-secondary">Issued: <b class="font-mono text-ink">{{ d.issueDate }}</b></span>
<span v-if="d.expiryDate" class="text-ink-secondary">Expires: <b class="font-mono text-ink">{{ d.expiryDate }}</b></span>
<span class="text-ink-secondary">File: <b class="text-ink">{{ d.hasFile ? d.fileName : 'none' }}</b></span>
</div>
<ul v-if="(d.expiry?.flags || []).length" class="mt-2 space-y-1">
<li v-for="(fl, i) in d.expiry.flags" :key="i" class="flex items-start gap-2 text-xs"
:class="d.expiry.state === 'expired' ? 'text-danger-fg' : d.expiry.state === 'expiring_soon' ? 'text-amber-fg' : 'text-ink-secondary'">
<Icon name="alertTriangle" :size="13" class="mt-px shrink-0" /> {{ fl }}
</li>
</ul>
<div v-else-if="d.expiry?.state === 'valid'" class="mt-2 text-xs text-success-fg">In force — no action needed.</div>
<div v-if="d.notes" class="mt-2 text-xs text-ink-secondary"><span class="text-ink-muted">Notes:</span> {{ d.notes }}</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</template>