diff --git a/archivist/internal/pbapp/setup.go b/archivist/internal/pbapp/setup.go index 29122c3..103f5d1 100644 --- a/archivist/internal/pbapp/setup.go +++ b/archivist/internal/pbapp/setup.go @@ -23,104 +23,136 @@ func Setup(app core.App, adminEmail, adminPassword string, cfg *config.Config) e if err := ensureSuperuser(app, adminEmail, adminPassword); err != nil { return err } - // Startup cleanup of the official enum value lists (enums.go). + // Startup cleanup of the official enum value lists (US-37, enums.go). CleanupEnumValues(app, cfg) return nil } -// ensureDocuments creates or checks the documents collection: status, -// file_key, then one column per declared field (US-03). The migration -// policy is explicit refusal: any deviation from the startup shape makes -// the application refuse to start rather than guess. func ensureDocuments(app core.App, cfg *config.Config) error { coll, err := app.FindCollectionByNameOrId("documents") - created := false if err != nil { + // First start: create the collection with its fixed columns (US-03). coll = core.NewBaseCollection("documents") - created = true } - // Fixed schema: status and file_key (US-03). - status := coll.Fields.GetByName("status") - if status == nil { - status = &core.SelectField{Name: "status", MaxSelect: 1, Values: []string{"pending", "processed", "validated"}} - coll.Fields.Add(status) - } else if status, ok := status.(*core.SelectField); !ok { - return errors.New("field status exists with a different type; refusing to start") - } + // Fixed columns (re)declared on every start, ordering preserved, plus + // the standard PocketBase created/updated autodate columns for + // convenience in the admin UI (US-03). + coll.Fields.RemoveByName("sha256") + coll.Fields.RemoveByName("file_key") + coll.Fields.RemoveByName("pages") + coll.Fields.RemoveByName("status") + coll.Fields.RemoveByName("created") + coll.Fields.RemoveByName("updated") + coll.Fields.Add( + &core.TextField{Name: "sha256"}, + &core.TextField{Name: "file_key"}, + &core.JSONField{Name: "pages"}, + &core.SelectField{Name: "status", MaxSelect: 1, Values: []string{"pending", "processed", "validated"}}, + &core.AutodateField{Name: "created", OnCreate: true}, + &core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true}, + ) - fileKey := coll.Fields.GetByName("file_key") - if fileKey == nil { - fileKey = &core.TextField{Name: "file_key"} - coll.Fields.Add(fileKey) - } else if _, ok := fileKey.(*core.TextField); !ok { - return errors.New("field file_key exists with a different type; refusing to start") - } - - // One column per declared field, with the type mapping US-03. + // One column per declared field, mapped from the field type (US-03). for _, f := range cfg.FieldList { var newField core.Field switch f.Type { - case "string": + case "text": newField = &core.TextField{Name: f.Name} + case "number": + newField = &core.NumberField{Name: f.Name} case "date": newField = &core.DateField{Name: f.Name} case "boolean": newField = &core.BoolField{Name: f.Name} case "enum": - // Official select fields: the application manages the value - // lists itself (enums.go) - creation with the placeholder, - // values added as documents are processed or from the - // dashboard, cleanup at startup. + // Official select fields (US-37): the application manages the + // value lists itself (enums.go) - creation with the dummy + // placeholder, or with the configured template names for the + // special "template" field; values added as documents are + // processed or from the dashboard; unused values cleaned up + // at startup. newField = &core.SelectField{Name: f.Name, MaxSelect: 1, Values: enumVocabulary(cfg, f.Name)} - default: - return fmt.Errorf("field %s has unsupported type %s", f.Name, f.Type) } - existing := coll.Fields.GetByName(f.Name) - if existing == nil { - coll.Fields.Add(newField) - } else if existing.Type() != newField.Type() { - return fmt.Errorf("field %s changed type (%s to %s); refusing to start", f.Name, existing.Type(), newField.Type()) + if existing := coll.Fields.GetByName(f.Name); existing != nil { + if existing.Type() != newField.Type() { + return fmt.Errorf("field %s changed type from %s to %s: refusing to start", + f.Name, existing.Type(), newField.Type()) + } + continue + } + coll.Fields.Add(newField) + } + + // Fields removed from the config are removed from the schema; values are + // lost by design (they remain in the event snapshots). + for _, f := range coll.Fields { + if f.GetSystem() { + continue + } + if _, declared := cfg.Fields[f.GetName()]; !declared && !isFixed(f.GetName()) { + app.Logger().Warn("documents: column removed", "field", f.GetName(), "reason", "removed from config") + coll.Fields.RemoveByName(f.GetName()) } } - if created { - return app.Save(coll) + if err := app.Save(coll); err != nil { + return fmt.Errorf("save documents collection: %w", err) + } + if _, err := app.DB().NewQuery( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_sha256 ON documents (sha256)").Execute(); err != nil { + return fmt.Errorf("sha256 unique index: %w", err) } return nil } -// ensureEvents creates the append-only events collection (US-04). +func isFixed(name string) bool { + for _, f := range []string{"sha256", "file_key", "pages", "status", "created", "updated"} { + if f == name { + return true + } + } + return false +} + func ensureEvents(app core.App) error { if _, err := app.FindCollectionByNameOrId(eventsName); err == nil { return nil } - coll := core.NewBaseCollection(eventsName) - for _, field := range []core.Field{ - &core.TextField{Name: "document"}, - &core.TextField{Name: "event_type"}, - &core.JSONField{Name: "payload"}, - &core.AutodateField{Name: "timestamp", OnCreate: true}, - } { - coll.Fields.Add(field) + docColl, err := app.FindCollectionByNameOrId("documents") + if err != nil { + return err } - return app.Save(coll) + coll := core.NewBaseCollection(eventsName) + coll.Fields.Add( + &core.RelationField{Name: "document", CollectionId: docColl.Id, MaxSelect: 1, CascadeDelete: true}, + &core.SelectField{Name: "event_type", MaxSelect: 1, Values: []string{"imported", "corrected", "validated"}}, + &core.JSONField{Name: "payload"}, + &core.DateField{Name: "timestamp"}, + &core.AutodateField{Name: "created", OnCreate: true}, + &core.AutodateField{Name: "updated", OnCreate: true, OnUpdate: true}, + ) + // Append-only (US-04): API rules locked to superusers are not enough, + // the standard admin UI uses a superuser; the request hooks in + // BindHooks reject updates and deletes for everyone. + if err := app.Save(coll); err != nil { + return fmt.Errorf("save %s collection: %w", eventsName, err) + } + return nil } -// ensureSuperuser upserts the administrator account (US-01). func ensureSuperuser(app core.App, email, password string) error { superusers, err := app.FindCollectionByNameOrId(core.CollectionNameSuperusers) if err != nil { return err } - existing, err := app.FindAuthRecordByEmail(superusers, email) - if err == nil && existing != nil { - return nil + rec, err := app.FindFirstRecordByFilter(superusers.Name, "email = {:e}", map[string]any{"e": email}) + if err != nil || rec == nil { + rec = core.NewRecord(superusers) + rec.SetEmail(email) } - record := core.NewRecord(superusers) - record.Email = email - if err := record.SetPassword(password); err != nil { - return err - } - return app.Save(record) + rec.SetPassword(password) + return app.Save(rec) } + +var errAppendOnly = errors.New("document_events is append-only: update and delete are refused") diff --git a/archivist/internal/pbapp/setup_test.go b/archivist/internal/pbapp/setup_test.go index 02647f4..24f61b0 100644 --- a/archivist/internal/pbapp/setup_test.go +++ b/archivist/internal/pbapp/setup_test.go @@ -9,72 +9,75 @@ import ( "github.com/napnap75/archivist/internal/config" ) -func enumTestCfg(t *testing.T) *config.Config { - t.Helper() - tpl, err := config.CompileTemplate("default", "Divers/{documentType}.pdf") - if err != nil { - t.Fatalf("compile template: %v", err) - } - fields := []config.Field{ - {Name: "template", Type: "enum"}, - {Name: "documentType", Type: "enum"}, - } - return &config.Config{ - Fields: map[string]config.Field{ - "template": fields[0], - "documentType": fields[1], - }, - FieldList: fields, - Templates: map[string]*config.CompiledTemplate{"default": tpl}, - } -} - -func enumTestApp(t *testing.T, cfg *config.Config) core.App { - t.Helper() +// TestEnsureDocumentsCreatesSelectEnums replaces the former text-storage +// workaround: declared enums are now official select fields (US-03, US-37), +// created with the single dummy placeholder. +func TestEnsureDocumentsCreatesSelectEnums(t *testing.T) { app := pocketbase.NewWithConfig(pocketbase.Config{DefaultDataDir: t.TempDir()}) if err := app.Bootstrap(); err != nil { t.Fatalf("bootstrap PocketBase: %v", err) } + defer app.ClearBootstrap() + + fields := []config.Field{ + {Name: "documentType", Type: "enum"}, + {Name: "bankAccount", Type: "enum"}, + } + cfg := &config.Config{ + Fields: map[string]config.Field{ + "documentType": fields[0], + "bankAccount": fields[1], + }, + FieldList: fields, + } if err := Setup(app, "admin@example.net", "password-123456", cfg); err != nil { t.Fatalf("setup: %v", err) } - return app -} - -// US-03: declared enums become official select fields; the "template" -// field starts with the configured template names, the others with the -// placeholder. -func TestEnsureDocumentsCreatesSelectEnums(t *testing.T) { - cfg := enumTestCfg(t) - app := enumTestApp(t, cfg) - defer app.ClearBootstrap() coll, err := app.FindCollectionByNameOrId("documents") if err != nil { t.Fatalf("find documents collection: %v", err) } - for name, want := range map[string][]string{ - "template": {"default"}, - "documentType": {DummyEnumValue}, - } { + for _, name := range []string{"documentType", "bankAccount"} { field, ok := coll.Fields.GetByName(name).(*core.SelectField) if !ok || field == nil { t.Fatalf("field %q is not a select field", name) } - if !reflect.DeepEqual(field.Values, want) { - t.Errorf("field %q values = %v, want %v", name, field.Values, want) + if !reflect.DeepEqual(field.Values, []string{DummyEnumValue}) { + t.Errorf("field %q values = %v, want [%s]", name, field.Values, DummyEnumValue) } } } -// US-37: the startup cleanup keeps the values documents use, drops the -// others, keeps the placeholder only while alone, and forces the template -// list to the configuration. +// TestCleanupEnumValues covers the startup cleanup (US-37): a value kept +// because a document uses it, an unused value dropped, the dummy +// placeholder kept only while alone, and the template field forced to the +// configured template names. func TestCleanupEnumValues(t *testing.T) { - cfg := enumTestCfg(t) - app := enumTestApp(t, cfg) + app := pocketbase.NewWithConfig(pocketbase.Config{DefaultDataDir: t.TempDir()}) + if err := app.Bootstrap(); err != nil { + t.Fatalf("bootstrap PocketBase: %v", err) + } defer app.ClearBootstrap() + fields := []config.Field{ + {Name: "documentType", Type: "enum"}, + {Name: "template", Type: "enum"}, + } + cfg := &config.Config{ + Fields: map[string]config.Field{ + "documentType": fields[0], + "template": fields[1], + }, + FieldList: fields, + Templates: map[string]*config.CompiledTemplate{ + "default": {}, + }, + } + if err := Setup(app, "admin@example.net", "password-123456", cfg); err != nil { + t.Fatalf("setup: %v", err) + } + if err := AddEnumValue(app, "documentType", "Facture"); err != nil { t.Fatalf("add enum value: %v", err) }