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"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -153,3 +155,105 @@ func (a *adminClient) do(ctx context.Context, method, path string, payload any)
|
|||||||
}
|
}
|
||||||
return data, status, nil
|
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("DELETE /api/flights/{id}", s.requireUser(s.handleDeleteFlight))
|
||||||
mux.HandleFunc("GET /api/logbook/export", s.requireUser(s.handleExportLogbook))
|
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.
|
// Device / dashboard API.
|
||||||
mux.HandleFunc("GET /api/devices", s.handleListDevices)
|
mux.HandleFunc("GET /api/devices", s.handleListDevices)
|
||||||
mux.HandleFunc("GET /api/devices/{id}/track", s.handleTrack)
|
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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -465,6 +465,70 @@ func (a *App) handleExportLogbook(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = io.Copy(w, resp.Body)
|
_, _ = 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.
|
// doRelay executes an outbound request and relays the response verbatim.
|
||||||
func (a *App) doRelay(w http.ResponseWriter, req *http.Request) {
|
func (a *App) doRelay(w http.ResponseWriter, req *http.Request) {
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+20
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-20
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -35,8 +35,8 @@
|
|||||||
})()
|
})()
|
||||||
</script>
|
</script>
|
||||||
<title>PilotVault — Control Panel</title>
|
<title>PilotVault — Control Panel</title>
|
||||||
<script type="module" crossorigin src="./assets/index-DLbqB6QP.js"></script>
|
<script type="module" crossorigin src="./assets/index-Bw9Fgeki.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-Co-T5CTN.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-B-aj3yVr.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ func main() {
|
|||||||
mux.HandleFunc("PATCH /bff/flights/{id}", app.requireAuth(app.handleUpdateFlight))
|
mux.HandleFunc("PATCH /bff/flights/{id}", app.requireAuth(app.handleUpdateFlight))
|
||||||
mux.HandleFunc("DELETE /bff/flights/{id}", app.requireAuth(app.handleDeleteFlight))
|
mux.HandleFunc("DELETE /bff/flights/{id}", app.requireAuth(app.handleDeleteFlight))
|
||||||
mux.HandleFunc("GET /bff/logbook/export", app.requireAuth(app.handleExportLogbook))
|
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 /bff/ws", app.handleWS)
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "api": app.apiBase})
|
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "api": app.apiBase})
|
||||||
|
|||||||
@@ -330,6 +330,54 @@ export function exportLogbookUrl() {
|
|||||||
return '/bff/logbook/export'
|
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) {
|
export async function sendCommand(id, command, payload) {
|
||||||
const r = await fetch(`/bff/devices/${encodeURIComponent(id)}/command`, {
|
const r = await fetch(`/bff/devices/${encodeURIComponent(id)}/command`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import BrandMark from './BrandMark.vue'
|
|||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
import Settings from './Settings.vue'
|
import Settings from './Settings.vue'
|
||||||
import Logbook from './Logbook.vue'
|
import Logbook from './Logbook.vue'
|
||||||
|
import Documents from './Documents.vue'
|
||||||
import { getDevices, sendCommand } from '../api.js'
|
import { getDevices, sendCommand } from '../api.js'
|
||||||
import { formatTime } from '../prefs.js'
|
import { formatTime } from '../prefs.js'
|
||||||
|
|
||||||
@@ -631,6 +632,9 @@ onBeforeUnmount(() => {
|
|||||||
<!-- ---------- Logbook ---------- -->
|
<!-- ---------- Logbook ---------- -->
|
||||||
<Logbook v-else-if="active === 'Logbook'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" />
|
<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 ---------- -->
|
||||||
<Settings v-else-if="active === 'Settings'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" @logout="emit('logout')" />
|
<Settings v-else-if="active === 'Settings'" :email="email" :role="role" :organization="organization" :organization-name="organizationName" @logout="emit('logout')" />
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user