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:
tajniak81
2026-07-19 15:35:33 +02:00
co-authored by Claude Opus 4.8
parent 1e76c2b7f9
commit 30c9cdebe9
17 changed files with 1042 additions and 23 deletions
+9
View File
@@ -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
+21
View File
@@ -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 {
+425
View File
@@ -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)
}
}
}
+218
View File
@@ -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`)",
},
}
+16
View File
@@ -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"),
}
}