Add compliance & operational document store (PocketBase)

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-13 19:46:18 +02:00
co-authored by Claude Opus 4.8
parent e3106d7b60
commit 52f84ad6cf
14 changed files with 1481 additions and 23 deletions
+104
View File
@@ -6,7 +6,9 @@ import (
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"net/url"
"sync"
"time"
)
@@ -153,3 +155,105 @@ func (a *adminClient) do(ctx context.Context, method, path string, payload any)
}
return data, status, nil
}
// multipartFile is one file part for a multipart upload.
type multipartFile struct {
field string
filename string
data []byte
}
// doMultipart performs an admin request with a multipart/form-data body (used to
// upload PocketBase file-field records). It mirrors do()'s self-healing re-auth:
// on a 401 it re-authenticates once and retries. The body is buffered so the
// retry can resend it.
func (a *adminClient) doMultipart(ctx context.Context, method, path string, fields map[string]string, files []multipartFile) ([]byte, int, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
for k, v := range fields {
if err := mw.WriteField(k, v); err != nil {
return nil, 0, err
}
}
for _, f := range files {
fw, err := mw.CreateFormFile(f.field, f.filename)
if err != nil {
return nil, 0, err
}
if _, err := fw.Write(f.data); err != nil {
return nil, 0, err
}
}
if err := mw.Close(); err != nil {
return nil, 0, err
}
contentType := mw.FormDataContentType()
body := buf.Bytes()
token := a.cachedToken()
if token == "" {
var err error
if token, err = a.authenticate(ctx); err != nil {
return nil, 0, err
}
}
baseURL, _, _ := a.creds()
send := func(tok string) ([]byte, int, error) {
req, _ := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
req.Header.Set("Authorization", tok)
req.Header.Set("Content-Type", contentType)
resp, err := a.client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
return data, resp.StatusCode, nil
}
data, status, err := send(token)
if err != nil {
return nil, 0, err
}
if status == http.StatusUnauthorized {
if token, err = a.authenticate(ctx); err != nil {
return nil, 0, err
}
return send(token)
}
return data, status, nil
}
// fileToken mints a short-lived PocketBase file-access token so protected files
// (the documents collection's rules are locked) can be fetched by URL.
func (a *adminClient) fileToken(ctx context.Context) (string, error) {
data, status, err := a.do(ctx, http.MethodPost, "/api/files/token", nil)
if err != nil {
return "", err
}
if status != http.StatusOK {
return "", errors.New("file token request failed: " + string(data))
}
var out struct {
Token string `json:"token"`
}
if err := json.Unmarshal(data, &out); err != nil || out.Token == "" {
return "", errors.New("file token: no token")
}
return out.Token, nil
}
// streamFile fetches a stored file for a record and returns the raw upstream
// response so the caller can copy its headers + body to the client. The caller
// must Close the returned Body.
func (a *adminClient) streamFile(ctx context.Context, collection, recordID, filename string) (*http.Response, error) {
tok, err := a.fileToken(ctx)
if err != nil {
return nil, err
}
baseURL, _, _ := a.creds()
fileURL := baseURL + "/api/files/" + url.PathEscape(collection) + "/" +
url.PathEscape(recordID) + "/" + url.PathEscape(filename) +
"?token=" + url.QueryEscape(tok)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)
return a.client.Do(req)
}