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
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user