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:
co-authored by
Claude Opus 4.8
parent
e3106d7b60
commit
52f84ad6cf
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user