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)