Add production Docker compose + on-startup PocketBase bootstrap
Introduce registry-pull production stacks (docker-compose.prod.yml) for both the multi-container Docker setup and the all-in-one Docker AIO image, with everything an operator needs (superuser, super-admin, ports, volumes) driven from .env. The API Server now bootstraps PocketBase on startup: a new internal/bootstrap package (Go port of setup-pocketbase.mjs) creates missing collections, reconciles existing ones, and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_* when absent. Idempotent and gated by PB_BOOTSTRAP. The PocketBase superuser is still upserted by the PocketBase container, since the REST API cannot bootstrap the first superuser. Move PocketBase to port 8070 (internal + published) and the web app to 8090 across both stacks, with matching CORS defaults. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1e76c2b7f9
commit
30c9cdebe9
@@ -288,6 +288,15 @@ The Node scripts read `POCKETBASE_URL` / `POCKETBASE_ADMIN_EMAIL` /
|
||||
> adding a new car spec field, extend `DESIRED.cars` in `setup-pocketbase.mjs`,
|
||||
> add it to `models.Car` + the record mapping in `records.go`, then rebuild.
|
||||
|
||||
> **Startup bootstrap:** the server also runs this same create/reconcile on boot
|
||||
> (`internal/bootstrap`, a Go mirror of `setup-pocketbase.mjs`) whenever a service
|
||||
> account is configured, so the Docker prod stack needs no manual setup step. It
|
||||
> is idempotent and gated by `PB_BOOTSTRAP` (default `true`; set to `false` to
|
||||
> skip). With `DRIVERVAULT_SUPERADMIN_EMAIL` + `DRIVERVAULT_SUPERADMIN_PASSWORD`
|
||||
> set it also creates the first `superadmin` user when absent. **Keep the two
|
||||
> schemas in sync:** a change to `DESIRED` in the script must be mirrored in
|
||||
> `internal/bootstrap/schema.go` (guarded by `TestSchemaConsistency`).
|
||||
|
||||
## First-time setup
|
||||
|
||||
1. **Create the PocketBase collections** (idempotent — safe to re-run on an
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"drivervault/apiserver/internal/api"
|
||||
"drivervault/apiserver/internal/bootstrap"
|
||||
"drivervault/apiserver/internal/config"
|
||||
"drivervault/apiserver/internal/pb"
|
||||
)
|
||||
@@ -44,6 +45,26 @@ func main() {
|
||||
log.Println("WARNING: POCKETBASE_ADMIN_EMAIL/PASSWORD unset — management endpoints return 503 until configured")
|
||||
}
|
||||
|
||||
// Bring PocketBase up to the expected schema (create missing collections,
|
||||
// reconcile existing ones) and ensure the super-admin. Idempotent, so it runs
|
||||
// on every boot. Non-fatal: a fresh PocketBase that isn't reachable yet, or a
|
||||
// bad service account, must not stop the panel from coming up so a superadmin
|
||||
// can log in and fix the connection.
|
||||
if cfg.Bootstrap && cfg.AdminConfigured() {
|
||||
bootCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
if err := bootstrap.Run(bootCtx, client, bootstrap.Options{
|
||||
UsersCollection: cfg.UsersCollection,
|
||||
SuperAdminEmail: cfg.SuperAdminEmail,
|
||||
SuperAdminPassword: cfg.SuperAdminPassword,
|
||||
SuperAdminName: cfg.SuperAdminName,
|
||||
}); err != nil {
|
||||
log.Printf("WARNING: bootstrap failed: %v", err)
|
||||
} else {
|
||||
log.Println("bootstrap: PocketBase schema ready")
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
|
||||
srv := api.New(cfg, client)
|
||||
|
||||
if err := srv.StartPlugins(); err != nil {
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
// Package bootstrap brings a fresh PocketBase up to the schema the API Server
|
||||
// expects, on server startup. It is the Go port of scripts/setup-pocketbase.mjs
|
||||
// and is idempotent: existing collections are reconciled (missing fields added,
|
||||
// relation/select options fixed) rather than recreated, and an already-present
|
||||
// super-admin user is left untouched.
|
||||
//
|
||||
// It creates the app-level collections and, optionally, the DriverVault
|
||||
// super-admin account. It does NOT create the PocketBase *superuser* — that is a
|
||||
// chicken-and-egg the REST API can't solve (creating a _superusers record needs
|
||||
// an existing superuser token), so the PocketBase container upserts it from env
|
||||
// on boot instead. Bootstrap authenticates with that same superuser.
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"drivervault/apiserver/internal/pb"
|
||||
)
|
||||
|
||||
// Options configures a bootstrap run. SuperAdmin* are optional: when either the
|
||||
// email or password is empty, the super-admin step is skipped.
|
||||
type Options struct {
|
||||
UsersCollection string
|
||||
SuperAdminEmail string
|
||||
SuperAdminPassword string
|
||||
SuperAdminName string
|
||||
}
|
||||
|
||||
// fieldDef is a schema field normalized to a single shape; it is rendered into
|
||||
// the right PocketBase wire format ("fields" for v0.23+, legacy "schema") per
|
||||
// the detected server version.
|
||||
type fieldDef struct {
|
||||
name string
|
||||
typ string
|
||||
required bool
|
||||
relTo string // relation target collection name
|
||||
cascadeDelete bool // relations only; default true
|
||||
values []string
|
||||
onCreate bool // autodate only
|
||||
onUpdate bool // autodate only
|
||||
maxSize int // file/json only, bytes
|
||||
mimeTypes []string
|
||||
}
|
||||
|
||||
// Field builders, mirroring the F.* helpers in setup-pocketbase.mjs.
|
||||
func fText(name string, required bool) fieldDef {
|
||||
return fieldDef{name: name, typ: "text", required: required}
|
||||
}
|
||||
func fNumber(name string) fieldDef { return fieldDef{name: name, typ: "number"} }
|
||||
func fBool(name string) fieldDef { return fieldDef{name: name, typ: "bool"} }
|
||||
func fDate(name string, required bool) fieldDef {
|
||||
return fieldDef{name: name, typ: "date", required: required}
|
||||
}
|
||||
func fRelation(name, relTo string, required, cascade bool) fieldDef {
|
||||
return fieldDef{name: name, typ: "relation", required: required, relTo: relTo, cascadeDelete: cascade}
|
||||
}
|
||||
func fSelect(name string, values []string, required bool) fieldDef {
|
||||
return fieldDef{name: name, typ: "select", required: required, values: values}
|
||||
}
|
||||
func fAutodate(name string, onCreate, onUpdate bool) fieldDef {
|
||||
return fieldDef{name: name, typ: "autodate", onCreate: onCreate, onUpdate: onUpdate}
|
||||
}
|
||||
func fFile(name string, maxSize int, mimeTypes []string) fieldDef {
|
||||
return fieldDef{name: name, typ: "file", maxSize: maxSize, mimeTypes: mimeTypes}
|
||||
}
|
||||
func fJSON(name string, maxSize int) fieldDef {
|
||||
return fieldDef{name: name, typ: "json", maxSize: maxSize}
|
||||
}
|
||||
|
||||
// attachment is the single optional file a record can carry (scan/receipt/photo).
|
||||
// The 10MB cap matches maxAttachmentUpload in the API server.
|
||||
func attachment() fieldDef {
|
||||
return fFile("file", 10485760, []string{
|
||||
"application/pdf", "image/jpeg", "image/png", "image/webp", "image/heic",
|
||||
})
|
||||
}
|
||||
|
||||
// renderField turns a fieldDef into the map PocketBase expects, in either the
|
||||
// modern ("fields") or legacy ("schema") layout. idByName resolves a relation's
|
||||
// target collection name to its id.
|
||||
func renderField(d fieldDef, format string, idByName map[string]string) map[string]any {
|
||||
if format == "schema" {
|
||||
options := map[string]any{}
|
||||
switch d.typ {
|
||||
case "relation":
|
||||
options["collectionId"] = idByName[d.relTo]
|
||||
options["cascadeDelete"] = d.cascadeDelete
|
||||
options["maxSelect"] = 1
|
||||
options["minSelect"] = 0
|
||||
case "select":
|
||||
options["values"] = d.values
|
||||
options["maxSelect"] = 1
|
||||
case "file":
|
||||
options["maxSelect"] = 1
|
||||
options["maxSize"] = d.maxSize
|
||||
options["mimeTypes"] = mimeOrEmpty(d.mimeTypes)
|
||||
case "json":
|
||||
options["maxSize"] = d.maxSize
|
||||
}
|
||||
return map[string]any{"name": d.name, "type": d.typ, "required": d.required, "options": options}
|
||||
}
|
||||
|
||||
field := map[string]any{"name": d.name, "type": d.typ, "required": d.required}
|
||||
switch d.typ {
|
||||
case "relation":
|
||||
field["collectionId"] = idByName[d.relTo]
|
||||
field["cascadeDelete"] = d.cascadeDelete
|
||||
field["maxSelect"] = 1
|
||||
field["minSelect"] = 0
|
||||
case "select":
|
||||
field["values"] = d.values
|
||||
field["maxSelect"] = 1
|
||||
case "autodate":
|
||||
field["onCreate"] = d.onCreate
|
||||
field["onUpdate"] = d.onUpdate
|
||||
case "file":
|
||||
field["maxSelect"] = 1
|
||||
field["maxSize"] = d.maxSize
|
||||
field["mimeTypes"] = mimeOrEmpty(d.mimeTypes)
|
||||
case "json":
|
||||
field["maxSize"] = d.maxSize
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
func mimeOrEmpty(m []string) []string {
|
||||
if m == nil {
|
||||
return []string{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Run authenticates as the superuser, creates any missing collections, reconciles
|
||||
// existing ones, and (when configured) ensures the DriverVault super-admin exists.
|
||||
// It is safe to call on every startup.
|
||||
func Run(ctx context.Context, client *pb.Client, opts Options) error {
|
||||
if err := client.Authenticate(ctx); err != nil {
|
||||
return fmt.Errorf("authenticate: %w", err)
|
||||
}
|
||||
|
||||
existing, err := listCollections(ctx, client)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list collections: %w", err)
|
||||
}
|
||||
format := detectFormat(existing)
|
||||
log.Printf("bootstrap: schema format %q", format)
|
||||
|
||||
present := make(map[string]bool, len(existing))
|
||||
idByName := make(map[string]string, len(existing))
|
||||
for _, c := range existing {
|
||||
present[c.Name] = true
|
||||
idByName[c.Name] = c.ID
|
||||
}
|
||||
|
||||
// Create in dependency order: organizations before users references it; cars
|
||||
// before its relations. "users" is PocketBase's built-in auth collection and
|
||||
// is never created here — only reconciled below.
|
||||
for _, name := range createOrder {
|
||||
if present[name] {
|
||||
continue
|
||||
}
|
||||
if err := createCollection(ctx, client, name, format, idByName); err != nil {
|
||||
return fmt.Errorf("create %s: %w", name, err)
|
||||
}
|
||||
log.Printf("bootstrap: ✓ %s created", name)
|
||||
// Refresh so later relations resolve newly-created collection ids.
|
||||
refreshed, err := listCollections(ctx, client)
|
||||
if err != nil {
|
||||
return fmt.Errorf("re-list collections: %w", err)
|
||||
}
|
||||
for _, c := range refreshed {
|
||||
present[c.Name] = true
|
||||
idByName[c.Name] = c.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile fields on existing collections (add missing + fix relation
|
||||
// cascade and select value lists).
|
||||
for _, name := range reconcileOrder {
|
||||
if err := reconcileFields(ctx, client, name, format, idByName); err != nil {
|
||||
return fmt.Errorf("reconcile %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureSuperAdmin(ctx, client, opts); err != nil {
|
||||
return fmt.Errorf("super-admin: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- PocketBase collection calls -------------------------------------------
|
||||
|
||||
type collectionMeta struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Fields []map[string]any `json:"fields"`
|
||||
Schema []map[string]any `json:"schema"`
|
||||
}
|
||||
|
||||
func listCollections(ctx context.Context, client *pb.Client) ([]collectionMeta, error) {
|
||||
raw, status, err := client.Raw(ctx, http.MethodGet, "/api/collections?perPage=200", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return nil, fmt.Errorf("status %d: %s", status, raw)
|
||||
}
|
||||
var env struct {
|
||||
Items []collectionMeta `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return env.Items, nil
|
||||
}
|
||||
|
||||
// detectFormat reports whether this PocketBase serializes fields under "fields"
|
||||
// (v0.23+) or the legacy "schema" key, defaulting to the modern format.
|
||||
func detectFormat(cols []collectionMeta) string {
|
||||
for _, c := range cols {
|
||||
if len(c.Fields) > 0 {
|
||||
return "fields"
|
||||
}
|
||||
if len(c.Schema) > 0 {
|
||||
return "schema"
|
||||
}
|
||||
}
|
||||
return "fields"
|
||||
}
|
||||
|
||||
func createCollection(ctx context.Context, client *pb.Client, name, format string, idByName map[string]string) error {
|
||||
rendered := make([]map[string]any, 0, len(collectionsSchema[name]))
|
||||
for _, d := range collectionsSchema[name] {
|
||||
rendered = append(rendered, renderField(d, format, idByName))
|
||||
}
|
||||
body := map[string]any{
|
||||
"name": name,
|
||||
"type": "base",
|
||||
format: rendered, // "fields" or "schema"
|
||||
// Rules left null => superuser-only access (the API Server is the only client).
|
||||
"listRule": nil,
|
||||
"viewRule": nil,
|
||||
"createRule": nil,
|
||||
"updateRule": nil,
|
||||
"deleteRule": nil,
|
||||
}
|
||||
if idx := indexes[name]; len(idx) > 0 {
|
||||
body["indexes"] = idx
|
||||
}
|
||||
raw, status, err := client.Raw(ctx, http.MethodPost, "/api/collections", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("status %d: %s", status, raw)
|
||||
}
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &created); err == nil {
|
||||
idByName[name] = created.ID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileFields brings an existing collection in line with the desired schema:
|
||||
// it appends missing fields and updates relation cascadeDelete and select value
|
||||
// lists on existing fields, preserving every existing field (including system
|
||||
// fields) and their ids.
|
||||
func reconcileFields(ctx context.Context, client *pb.Client, name, format string, idByName map[string]string) error {
|
||||
raw, status, err := client.Raw(ctx, http.MethodGet, "/api/collections/"+name, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("get: status %d: %s", status, raw)
|
||||
}
|
||||
var col collectionMeta
|
||||
if err := json.Unmarshal(raw, &col); err != nil {
|
||||
return err
|
||||
}
|
||||
current := col.Fields
|
||||
if format == "schema" {
|
||||
current = col.Schema
|
||||
}
|
||||
|
||||
defs := collectionsSchema[name]
|
||||
defByName := make(map[string]fieldDef, len(defs))
|
||||
for _, d := range defs {
|
||||
defByName[d.name] = d
|
||||
}
|
||||
|
||||
var changes []string
|
||||
merged := make([]map[string]any, 0, len(current)+len(defs))
|
||||
haveName := make(map[string]bool, len(current))
|
||||
for _, f := range current {
|
||||
fname, _ := f["name"].(string)
|
||||
haveName[fname] = true
|
||||
if def, ok := defByName[fname]; ok {
|
||||
switch def.typ {
|
||||
case "relation":
|
||||
if asBool(f["cascadeDelete"]) != def.cascadeDelete {
|
||||
f["cascadeDelete"] = def.cascadeDelete
|
||||
changes = append(changes, fname+".cascadeDelete")
|
||||
}
|
||||
case "select":
|
||||
if !sameValues(f["values"], def.values) {
|
||||
f["values"] = def.values
|
||||
changes = append(changes, fname+".values")
|
||||
}
|
||||
}
|
||||
}
|
||||
merged = append(merged, f)
|
||||
}
|
||||
// Append missing fields.
|
||||
for _, d := range defs {
|
||||
if !haveName[d.name] {
|
||||
merged = append(merged, renderField(d, format, idByName))
|
||||
changes = append(changes, "+"+d.name)
|
||||
}
|
||||
}
|
||||
|
||||
if len(changes) == 0 {
|
||||
log.Printf("bootstrap: • %s up to date", name)
|
||||
return nil
|
||||
}
|
||||
patch := map[string]any{format: merged}
|
||||
raw, status, err = client.Raw(ctx, http.MethodPatch, "/api/collections/"+col.ID, patch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("patch: status %d: %s", status, raw)
|
||||
}
|
||||
log.Printf("bootstrap: ✓ %s — %v", name, changes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureSuperAdmin creates the DriverVault super-admin user when it does not yet
|
||||
// exist. An already-present account (matched by email) is left untouched.
|
||||
func ensureSuperAdmin(ctx context.Context, client *pb.Client, opts Options) error {
|
||||
if opts.SuperAdminEmail == "" || opts.SuperAdminPassword == "" {
|
||||
return nil // not requested
|
||||
}
|
||||
coll := opts.UsersCollection
|
||||
if coll == "" {
|
||||
coll = "users"
|
||||
}
|
||||
|
||||
filter := url.QueryEscape(fmt.Sprintf("email='%s'", opts.SuperAdminEmail))
|
||||
raw, status, err := client.Raw(ctx, http.MethodGet,
|
||||
"/api/collections/"+coll+"/records?perPage=1&filter="+filter, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("lookup: status %d: %s", status, raw)
|
||||
}
|
||||
var found struct {
|
||||
TotalItems int `json:"totalItems"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &found); err != nil {
|
||||
return err
|
||||
}
|
||||
if found.TotalItems > 0 {
|
||||
log.Printf("bootstrap: • super-admin %s already exists", opts.SuperAdminEmail)
|
||||
return nil
|
||||
}
|
||||
|
||||
name := opts.SuperAdminName
|
||||
if name == "" {
|
||||
name = "Administrator"
|
||||
}
|
||||
create := map[string]any{
|
||||
"email": opts.SuperAdminEmail,
|
||||
"password": opts.SuperAdminPassword,
|
||||
"passwordConfirm": opts.SuperAdminPassword,
|
||||
"name": name,
|
||||
"role": "superadmin",
|
||||
"verified": true,
|
||||
"emailVisibility": false,
|
||||
}
|
||||
raw, status, err = client.Raw(ctx, http.MethodPost, "/api/collections/"+coll+"/records", create)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
return fmt.Errorf("create: status %d: %s", status, raw)
|
||||
}
|
||||
log.Printf("bootstrap: ✓ super-admin %s created", opts.SuperAdminEmail)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
|
||||
func asBool(v any) bool {
|
||||
b, _ := v.(bool)
|
||||
return b
|
||||
}
|
||||
|
||||
// sameValues reports whether a select field's current values equal the desired
|
||||
// set (order-insensitive), matching the reconcile check in setup-pocketbase.mjs.
|
||||
func sameValues(current any, want []string) bool {
|
||||
arr, ok := current.([]any)
|
||||
if !ok || len(arr) != len(want) {
|
||||
return false
|
||||
}
|
||||
have := make(map[string]bool, len(arr))
|
||||
for _, v := range arr {
|
||||
if s, ok := v.(string); ok {
|
||||
have[s] = true
|
||||
}
|
||||
}
|
||||
for _, w := range want {
|
||||
if !have[w] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package bootstrap
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestRenderFieldModern locks the v0.23+ ("fields") wire shape for each field
|
||||
// type against what PocketBase expects.
|
||||
func TestRenderFieldModern(t *testing.T) {
|
||||
idByName := map[string]string{"cars": "col_cars"}
|
||||
|
||||
rel := renderField(fRelation("car", "cars", true, true), "fields", idByName)
|
||||
if rel["collectionId"] != "col_cars" || rel["cascadeDelete"] != true ||
|
||||
rel["maxSelect"] != 1 || rel["minSelect"] != 0 || rel["required"] != true {
|
||||
t.Fatalf("relation rendered wrong: %#v", rel)
|
||||
}
|
||||
|
||||
nonCascade := renderField(fRelation("owner", "users", false, false), "fields", map[string]string{"users": "u"})
|
||||
if nonCascade["cascadeDelete"] != false {
|
||||
t.Fatalf("non-cascading relation should keep cascadeDelete=false: %#v", nonCascade)
|
||||
}
|
||||
|
||||
sel := renderField(fSelect("result", []string{"passed", "failed"}, false), "fields", idByName)
|
||||
if sel["maxSelect"] != 1 {
|
||||
t.Fatalf("select maxSelect: %#v", sel)
|
||||
}
|
||||
if vals, ok := sel["values"].([]string); !ok || len(vals) != 2 {
|
||||
t.Fatalf("select values: %#v", sel["values"])
|
||||
}
|
||||
|
||||
ad := renderField(fAutodate("created", true, false), "fields", idByName)
|
||||
if ad["onCreate"] != true || ad["onUpdate"] != false {
|
||||
t.Fatalf("autodate: %#v", ad)
|
||||
}
|
||||
|
||||
file := renderField(attachment(), "fields", idByName)
|
||||
if file["maxSize"] != 10485760 || file["maxSelect"] != 1 {
|
||||
t.Fatalf("file: %#v", file)
|
||||
}
|
||||
if mt, ok := file["mimeTypes"].([]string); !ok || len(mt) != 5 {
|
||||
t.Fatalf("file mimeTypes: %#v", file["mimeTypes"])
|
||||
}
|
||||
|
||||
j := renderField(fJSON("params", 10000), "fields", idByName)
|
||||
if j["maxSize"] != 10000 {
|
||||
t.Fatalf("json: %#v", j)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderFieldLegacy checks that the legacy ("schema") layout nests options.
|
||||
func TestRenderFieldLegacy(t *testing.T) {
|
||||
rel := renderField(fRelation("car", "cars", true, true), "schema", map[string]string{"cars": "col_cars"})
|
||||
opts, ok := rel["options"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("legacy relation should nest options: %#v", rel)
|
||||
}
|
||||
if opts["collectionId"] != "col_cars" || opts["cascadeDelete"] != true {
|
||||
t.Fatalf("legacy relation options: %#v", opts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSameValues covers the select-reconcile comparison (order-insensitive).
|
||||
func TestSameValues(t *testing.T) {
|
||||
if !sameValues([]any{"a", "b"}, []string{"b", "a"}) {
|
||||
t.Fatal("same set in different order should compare equal")
|
||||
}
|
||||
if sameValues([]any{"a"}, []string{"a", "b"}) {
|
||||
t.Fatal("differing lengths should not be equal")
|
||||
}
|
||||
if sameValues([]any{"a", "c"}, []string{"a", "b"}) {
|
||||
t.Fatal("differing members should not be equal")
|
||||
}
|
||||
if sameValues(nil, []string{"a"}) {
|
||||
t.Fatal("nil current should not equal non-empty want")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSchemaConsistency guards the ordered lists against the schema map.
|
||||
func TestSchemaConsistency(t *testing.T) {
|
||||
for _, name := range createOrder {
|
||||
if _, ok := collectionsSchema[name]; !ok {
|
||||
t.Errorf("createOrder references unknown collection %q", name)
|
||||
}
|
||||
if name == "users" {
|
||||
t.Errorf("users must not be in createOrder (built-in auth collection)")
|
||||
}
|
||||
}
|
||||
for _, name := range reconcileOrder {
|
||||
if _, ok := collectionsSchema[name]; !ok {
|
||||
t.Errorf("reconcileOrder references unknown collection %q", name)
|
||||
}
|
||||
}
|
||||
for name := range indexes {
|
||||
if _, ok := collectionsSchema[name]; !ok {
|
||||
t.Errorf("indexes references unknown collection %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package bootstrap
|
||||
|
||||
// This file is the Go mirror of the DESIRED schema, create order, reconcile
|
||||
// order and INDEXES in scripts/setup-pocketbase.mjs. Keep the two in sync: edit
|
||||
// here and re-run the server (bootstrap applies on startup), or run the script.
|
||||
//
|
||||
// Access rules are left null on every collection on purpose — every client goes
|
||||
// through the API Server, which authenticates as a superuser, so the database is
|
||||
// never exposed directly (including attachments, proxied by the API).
|
||||
|
||||
// collectionsSchema is the desired field set per collection.
|
||||
var collectionsSchema = map[string][]fieldDef{
|
||||
"cars": {
|
||||
fText("name", true),
|
||||
fText("make", false),
|
||||
fText("model", false),
|
||||
fNumber("year"),
|
||||
fText("registration", false),
|
||||
fText("registration_country", false),
|
||||
fText("vin", false),
|
||||
fNumber("service_interval_days"),
|
||||
fNumber("service_interval_km"),
|
||||
// Roadworthiness inspection cycle. Only prefills a check's next-due date.
|
||||
fNumber("technical_check_interval_days"),
|
||||
fText("oil_spec", false),
|
||||
fText("transmission_oil_spec", false),
|
||||
fText("differential_oil_spec", false),
|
||||
fText("brake_fluid_spec", false),
|
||||
fText("coolant_spec", false),
|
||||
fNumber("current_km"),
|
||||
// Bi-fuel LPG conversions are their own choice: the car runs on either tank.
|
||||
fSelect("fuel_type", []string{
|
||||
"petrol", "petrol_lpg", "diesel", "diesel_lpg", "hybrid", "electric", "hydrogen",
|
||||
}, false),
|
||||
fText("build_date", false), // ISO YYYY-MM-DD (date-only)
|
||||
fText("first_registration_date", false), // ISO YYYY-MM-DD
|
||||
// Owner of this car. Non-cascading: deleting a user must not wipe their cars.
|
||||
fRelation("owner", "users", false, false),
|
||||
},
|
||||
"service_records": {
|
||||
fRelation("car", "cars", true, true),
|
||||
fDate("date", true),
|
||||
fNumber("km"),
|
||||
fBool("changed_oil"),
|
||||
fBool("changed_engine_air_filter"),
|
||||
fBool("changed_cabin_air_filter"),
|
||||
fText("notes", false),
|
||||
attachment(), // the workshop receipt / stamped service-book page
|
||||
},
|
||||
// Mandatory roadworthiness inspections (przegląd techniczny / MOT / TÜV).
|
||||
"technical_checks": {
|
||||
fRelation("car", "cars", true, true),
|
||||
fDate("date", true),
|
||||
fSelect("result", []string{"passed", "failed"}, false),
|
||||
fNumber("cost"),
|
||||
fText("station", false),
|
||||
fDate("valid_until", false),
|
||||
fText("notes", false),
|
||||
attachment(), // the certificate
|
||||
},
|
||||
"parts": {
|
||||
fRelation("car", "cars", true, true),
|
||||
fText("name", true),
|
||||
fText("part_number", false),
|
||||
fText("category", false),
|
||||
fText("notes", false),
|
||||
attachment(), // a photo of the box, or the part's spec sheet
|
||||
},
|
||||
// Fuel refills. Consumption is derived on read, not stored.
|
||||
"fuel_entries": {
|
||||
fRelation("car", "cars", true, true),
|
||||
fDate("date", true),
|
||||
fNumber("km"), // odometer at the pump
|
||||
fNumber("liters"),
|
||||
fNumber("cost"),
|
||||
fBool("full_tank"),
|
||||
fBool("missed_fill"),
|
||||
fText("station", false),
|
||||
fText("notes", false),
|
||||
attachment(), // the pump receipt
|
||||
},
|
||||
// Workshop visits and repairs (unplanned/one-off garage work with a labour bill).
|
||||
"maintenance_entries": {
|
||||
fRelation("car", "cars", true, true),
|
||||
fDate("date", true),
|
||||
fNumber("km"),
|
||||
fSelect("type", []string{"repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other"}, false),
|
||||
fSelect("status", []string{"scheduled", "in_progress", "completed"}, false),
|
||||
fText("workshop", false),
|
||||
fText("location", false),
|
||||
fText("description", false),
|
||||
fText("parts_used", false),
|
||||
fNumber("labor_cost"),
|
||||
fNumber("parts_cost"),
|
||||
fText("invoice_number", false),
|
||||
fDate("warranty_until", false),
|
||||
fText("notes", false),
|
||||
attachment(), // the workshop's invoice
|
||||
},
|
||||
// Insurance, pollution certificates, registration papers … expiry drives reminders.
|
||||
"car_documents": {
|
||||
fRelation("car", "cars", true, true),
|
||||
fSelect("type", []string{"insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other"}, false),
|
||||
fText("title", true),
|
||||
fText("provider", false),
|
||||
fText("reference", false),
|
||||
fDate("issue_date", false),
|
||||
fDate("expiry_date", false),
|
||||
fNumber("cost"),
|
||||
fText("notes", false),
|
||||
attachment(), // the scan/PDF of the paperwork
|
||||
},
|
||||
// User-set reminders (derived document/service ones are computed on read).
|
||||
"reminders": {
|
||||
fRelation("car", "cars", true, true),
|
||||
fText("title", true),
|
||||
fSelect("type", []string{"maintenance", "document", "service", "inspection", "other"}, false),
|
||||
fDate("due_date", false),
|
||||
fNumber("due_km"),
|
||||
fNumber("repeat_days"),
|
||||
fNumber("repeat_km"),
|
||||
fBool("done"),
|
||||
fDate("done_at", false),
|
||||
fText("notes", false),
|
||||
},
|
||||
// Per-car sharing grants. Cascades on both relations.
|
||||
"car_shares": {
|
||||
fRelation("car", "cars", true, true),
|
||||
fRelation("user", "users", true, true),
|
||||
fSelect("permission", []string{"read", "write"}, true),
|
||||
fAutodate("created", true, false),
|
||||
},
|
||||
// Append-only audit trail for OCPP charger control. Actor/org stored as plain
|
||||
// text ids (not relations) so the trail survives user or org deletion.
|
||||
"control_audit": {
|
||||
fText("user_id", false),
|
||||
fText("org_id", false),
|
||||
fText("serial", false),
|
||||
fText("action", true),
|
||||
fText("result", false),
|
||||
fJSON("params", 10000),
|
||||
fAutodate("created", true, false),
|
||||
},
|
||||
// Tenants that users belong to.
|
||||
"organizations": {
|
||||
fText("name", true),
|
||||
fAutodate("created", true, false),
|
||||
// Per-organization plugin/integration config (middle layer of the cascade).
|
||||
fJSON("pluginSettings", 100000),
|
||||
},
|
||||
// Custom fields layered onto the built-in "users" auth collection.
|
||||
"users": {
|
||||
fText("bio", false),
|
||||
fSelect("theme", []string{"light", "dark", "system"}, false),
|
||||
fText("locale", false),
|
||||
fSelect("date_format", []string{"YMD", "DMY_NUM", "DMY", "MDY"}, false),
|
||||
fSelect("currency", []string{
|
||||
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
|
||||
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
|
||||
"TRY", "UAH", "USD", "CAD", "AUD", "JPY",
|
||||
}, false),
|
||||
fSelect("font_size", []string{"small", "medium", "large"}, false),
|
||||
fDate("deletion_requested_at", false),
|
||||
// Access role. Empty value is treated as "user" by the API.
|
||||
fSelect("role", []string{"user", "admin", "superadmin"}, false),
|
||||
// Organization membership. Non-cascading: deleting an org keeps its people.
|
||||
fRelation("organization", "organizations", false, false),
|
||||
// Per-user plugin/integration config (bottom layer of the cascade).
|
||||
fJSON("pluginSettings", 100000),
|
||||
},
|
||||
}
|
||||
|
||||
// createOrder is the dependency order for creating missing collections.
|
||||
// "users" is PocketBase's built-in auth collection and is never created here.
|
||||
var createOrder = []string{
|
||||
"organizations",
|
||||
"cars",
|
||||
"service_records",
|
||||
"technical_checks",
|
||||
"parts",
|
||||
"car_shares",
|
||||
"fuel_entries",
|
||||
"maintenance_entries",
|
||||
"car_documents",
|
||||
"reminders",
|
||||
"control_audit",
|
||||
}
|
||||
|
||||
// reconcileOrder additionally includes "users" so its custom fields (role,
|
||||
// organization, preferences) are added to the built-in collection.
|
||||
var reconcileOrder = []string{
|
||||
"organizations",
|
||||
"users",
|
||||
"cars",
|
||||
"service_records",
|
||||
"technical_checks",
|
||||
"parts",
|
||||
"car_shares",
|
||||
"fuel_entries",
|
||||
"maintenance_entries",
|
||||
"car_documents",
|
||||
"reminders",
|
||||
"control_audit",
|
||||
}
|
||||
|
||||
// indexes are extra SQL indexes applied at collection-create time.
|
||||
var indexes = map[string][]string{
|
||||
"organizations": {"CREATE UNIQUE INDEX `idx_organizations_name` ON `organizations` (`name`)"},
|
||||
"fuel_entries": {"CREATE INDEX `idx_fuel_entries_car_km` ON `fuel_entries` (`car`, `km`)"},
|
||||
"maintenance_entries": {"CREATE INDEX `idx_maintenance_entries_car_date` ON `maintenance_entries` (`car`, `date`)"},
|
||||
"car_documents": {"CREATE INDEX `idx_car_documents_car_expiry` ON `car_documents` (`car`, `expiry_date`)"},
|
||||
"reminders": {"CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"},
|
||||
"technical_checks": {"CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"},
|
||||
"control_audit": {
|
||||
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
|
||||
"CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)",
|
||||
},
|
||||
}
|
||||
@@ -36,6 +36,18 @@ type Config struct {
|
||||
// the charger at, instead of deriving it from request headers.
|
||||
OCPPRequireTLS bool
|
||||
OCPPPublicURL string
|
||||
|
||||
// Bootstrap controls the on-startup PocketBase schema setup: when true (the
|
||||
// default) the server creates any missing collections and reconciles existing
|
||||
// ones against the desired schema, then ensures the super-admin below exists.
|
||||
// It requires a configured service account; without one it is a no-op.
|
||||
Bootstrap bool
|
||||
|
||||
// Super-admin account created during bootstrap when both fields are set and no
|
||||
// user with that email exists yet. Existing accounts are left untouched.
|
||||
SuperAdminEmail string
|
||||
SuperAdminPassword string
|
||||
SuperAdminName string
|
||||
}
|
||||
|
||||
// EnvFile is the .env path (relative to the working directory) that Load reads
|
||||
@@ -63,6 +75,10 @@ func Load() Config {
|
||||
PocketBaseAdminPassword: firstEnv("POCKETBASE_ADMIN_PASSWORD", "PB_ADMIN_PASSWORD"),
|
||||
OCPPRequireTLS: boolEnv("OCPP_REQUIRE_TLS", true),
|
||||
OCPPPublicURL: strings.TrimRight(getenv("OCPP_PUBLIC_URL", ""), "/"),
|
||||
Bootstrap: boolEnv("PB_BOOTSTRAP", true),
|
||||
SuperAdminEmail: firstEnv("DRIVERVAULT_SUPERADMIN_EMAIL", "SUPERADMIN_EMAIL"),
|
||||
SuperAdminPassword: firstEnv("DRIVERVAULT_SUPERADMIN_PASSWORD", "SUPERADMIN_PASSWORD"),
|
||||
SuperAdminName: getenv("DRIVERVAULT_SUPERADMIN_NAME", "Administrator"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user