diff --git a/apis/collection.go b/apis/collection.go index cff3afe0..e06ec81a 100644 --- a/apis/collection.go +++ b/apis/collection.go @@ -169,19 +169,37 @@ func (api *collectionApi) delete(c echo.Context) error { return handlerErr } -// @todo add event func (api *collectionApi) bulkImport(c echo.Context) error { form := forms.NewCollectionsImport(api.app) - // load request + // load request data if err := c.Bind(form); err != nil { return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err) } - submitErr := form.Submit() - if submitErr != nil { - return rest.NewBadRequestError("Failed to import the submitted collections.", submitErr) + event := &core.CollectionsImportEvent{ + HttpContext: c, + Collections: form.Collections, } - return c.NoContent(http.StatusNoContent) + // import collections + submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc { + return func() error { + return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error { + form.Collections = e.Collections // ensures that the form always has the latest changes + + if err := next(); err != nil { + return rest.NewBadRequestError("Failed to import the submitted collections.", err) + } + + return e.HttpContext.NoContent(http.StatusNoContent) + }) + } + }) + + if submitErr == nil { + api.app.OnCollectionsAfterImportRequest().Trigger(event) + } + + return submitErr } diff --git a/apis/collection_test.go b/apis/collection_test.go index 79d8a63c..1f75aacb 100644 --- a/apis/collection_test.go +++ b/apis/collection_test.go @@ -440,3 +440,57 @@ func TestCollectionUpdate(t *testing.T) { scenario.Test(t) } } + +func TestCollectionImport(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "unauthorized", + Method: http.MethodPut, + Url: "/api/collections/import", + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as user", + Method: http.MethodPut, + Url: "/api/collections/import", + RequestHeaders: map[string]string{ + "Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic", + }, + ExpectedStatus: 401, + ExpectedContent: []string{`"data":{}`}, + }, + { + Name: "authorized as admin + empty collections", + Method: http.MethodPut, + Url: "/api/collections/import", + Body: strings.NewReader(`{"collections":[]}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":{`, + `"collections":{"code":"validation_required"`, + }, + }, + { + Name: "authorized as admin + deleting collections", + Method: http.MethodPut, + Url: "/api/collections/import", + Body: strings.NewReader(`{"collections":[]}`), + RequestHeaders: map[string]string{ + "Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo", + }, + ExpectedStatus: 400, + ExpectedContent: []string{ + `"data":{`, + `"collections":{"code":"validation_required"`, + }, + }, + } + + for _, scenario := range scenarios { + scenario.Test(t) + } +} diff --git a/core/app.go b/core/app.go index f89ad853..c19c06b6 100644 --- a/core/app.go +++ b/core/app.go @@ -119,7 +119,7 @@ type App interface { // sending a password reset email to an admin. // // Could be used to send your own custom email template if - // hook.StopPropagation is returned in one of its listeners. + // [hook.StopPropagation] is returned in one of its listeners. OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent] // OnMailerAfterAdminResetPasswordSend hook is triggered after @@ -130,7 +130,7 @@ type App interface { // sending a password reset email to a user. // // Could be used to send your own custom email template if - // hook.StopPropagation is returned in one of its listeners. + // [hook.StopPropagation] is returned in one of its listeners. OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent] // OnMailerAfterUserResetPasswordSend hook is triggered after @@ -141,7 +141,7 @@ type App interface { // sending a verification email to a user. // // Could be used to send your own custom email template if - // hook.StopPropagation is returned in one of its listeners. + // [hook.StopPropagation] is returned in one of its listeners. OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent] // OnMailerAfterUserVerificationSend hook is triggered after a user @@ -152,7 +152,7 @@ type App interface { // sending a confirmation new address email to a a user. // // Could be used to send your own custom email template if - // hook.StopPropagation is returned in one of its listeners. + // [hook.StopPropagation] is returned in one of its listeners. OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent] // OnMailerAfterUserChangeEmailSend hook is triggered after a user @@ -192,7 +192,7 @@ type App interface { // // Could be used to additionally validate the request data or // implement completely different persistence behavior - // (returning hook.StopPropagation). + // (returning [hook.StopPropagation]). OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent] // OnSettingsAfterUpdateRequest hook is triggered after each @@ -227,7 +227,7 @@ type App interface { // Admin create request (after request data load and before model persistence). // // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning hook.StopPropagation). + // completely different persistence behavior (returning [hook.StopPropagation]). OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent] // OnAdminAfterCreateRequest hook is triggered after each @@ -238,7 +238,7 @@ type App interface { // Admin update request (after request data load and before model persistence). // // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning hook.StopPropagation). + // completely different persistence behavior (returning [hook.StopPropagation]). OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent] // OnAdminAfterUpdateRequest hook is triggered after each @@ -249,7 +249,7 @@ type App interface { // Admin delete request (after model load and before actual deletion). // // Could be used to additionally validate the request data or implement - // completely different delete behavior (returning hook.StopPropagation). + // completely different delete behavior (returning [hook.StopPropagation]). OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent] // OnAdminAfterDeleteRequest hook is triggered after each @@ -281,7 +281,7 @@ type App interface { // create request (after request data load and before model persistence). // // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning hook.StopPropagation). + // completely different persistence behavior (returning [hook.StopPropagation]). OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent] // OnUserAfterCreateRequest hook is triggered after each @@ -292,7 +292,7 @@ type App interface { // update request (after request data load and before model persistence). // // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning hook.StopPropagation). + // completely different persistence behavior (returning [hook.StopPropagation]). OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent] // OnUserAfterUpdateRequest hook is triggered after each @@ -303,7 +303,7 @@ type App interface { // delete request (after model load and before actual deletion). // // Could be used to additionally validate the request data or implement - // completely different delete behavior (returning hook.StopPropagation). + // completely different delete behavior (returning [hook.StopPropagation]). OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent] // OnUserAfterDeleteRequest hook is triggered after each @@ -346,7 +346,7 @@ type App interface { // create request (after request data load and before model persistence). // // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning hook.StopPropagation). + // completely different persistence behavior (returning [hook.StopPropagation]). OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent] // OnRecordAfterCreateRequest hook is triggered after each @@ -357,7 +357,7 @@ type App interface { // update request (after request data load and before model persistence). // // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning hook.StopPropagation). + // completely different persistence behavior (returning [hook.StopPropagation]). OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent] // OnRecordAfterUpdateRequest hook is triggered after each @@ -368,7 +368,7 @@ type App interface { // delete request (after model load and before actual deletion). // // Could be used to additionally validate the request data or implement - // completely different delete behavior (returning hook.StopPropagation). + // completely different delete behavior (returning [hook.StopPropagation]). OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent] // OnRecordAfterDeleteRequest hook is triggered after each @@ -393,7 +393,7 @@ type App interface { // create request (after request data load and before model persistence). // // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning hook.StopPropagation). + // completely different persistence behavior (returning [hook.StopPropagation]). OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent] // OnCollectionAfterCreateRequest hook is triggered after each @@ -404,7 +404,7 @@ type App interface { // update request (after request data load and before model persistence). // // Could be used to additionally validate the request data or implement - // completely different persistence behavior (returning hook.StopPropagation). + // completely different persistence behavior (returning [hook.StopPropagation]). OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent] // OnCollectionAfterUpdateRequest hook is triggered after each @@ -415,10 +415,21 @@ type App interface { // Collection delete request (after model load and before actual deletion). // // Could be used to additionally validate the request data or implement - // completely different delete behavior (returning hook.StopPropagation). + // completely different delete behavior (returning [hook.StopPropagation]). OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent] // OnCollectionAfterDeleteRequest hook is triggered after each // successful API Collection delete request. OnCollectionAfterDeleteRequest() *hook.Hook[*CollectionDeleteEvent] + + // OnCollectionsBeforeImportRequest hook is triggered before each API + // collections import request (after request data load and before the actual import). + // + // Could be used to additionally validate the imported collections or + // to implement completely different import behavior (returning [hook.StopPropagation]). + OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent] + + // OnCollectionsAfterImportRequest hook is triggered after each + // successful API collections import request. + OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImportEvent] } diff --git a/core/base.go b/core/base.go index 8c31230b..d17d93fe 100644 --- a/core/base.go +++ b/core/base.go @@ -109,14 +109,16 @@ type BaseApp struct { onRecordAfterDeleteRequest *hook.Hook[*RecordDeleteEvent] // collection api event hooks - onCollectionsListRequest *hook.Hook[*CollectionsListEvent] - onCollectionViewRequest *hook.Hook[*CollectionViewEvent] - onCollectionBeforeCreateRequest *hook.Hook[*CollectionCreateEvent] - onCollectionAfterCreateRequest *hook.Hook[*CollectionCreateEvent] - onCollectionBeforeUpdateRequest *hook.Hook[*CollectionUpdateEvent] - onCollectionAfterUpdateRequest *hook.Hook[*CollectionUpdateEvent] - onCollectionBeforeDeleteRequest *hook.Hook[*CollectionDeleteEvent] - onCollectionAfterDeleteRequest *hook.Hook[*CollectionDeleteEvent] + onCollectionsListRequest *hook.Hook[*CollectionsListEvent] + onCollectionViewRequest *hook.Hook[*CollectionViewEvent] + onCollectionBeforeCreateRequest *hook.Hook[*CollectionCreateEvent] + onCollectionAfterCreateRequest *hook.Hook[*CollectionCreateEvent] + onCollectionBeforeUpdateRequest *hook.Hook[*CollectionUpdateEvent] + onCollectionAfterUpdateRequest *hook.Hook[*CollectionUpdateEvent] + onCollectionBeforeDeleteRequest *hook.Hook[*CollectionDeleteEvent] + onCollectionAfterDeleteRequest *hook.Hook[*CollectionDeleteEvent] + onCollectionsBeforeImportRequest *hook.Hook[*CollectionsImportEvent] + onCollectionsAfterImportRequest *hook.Hook[*CollectionsImportEvent] } // NewBaseApp creates and returns a new BaseApp instance @@ -201,14 +203,16 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp { onRecordAfterDeleteRequest: &hook.Hook[*RecordDeleteEvent]{}, // collection API event hooks - onCollectionsListRequest: &hook.Hook[*CollectionsListEvent]{}, - onCollectionViewRequest: &hook.Hook[*CollectionViewEvent]{}, - onCollectionBeforeCreateRequest: &hook.Hook[*CollectionCreateEvent]{}, - onCollectionAfterCreateRequest: &hook.Hook[*CollectionCreateEvent]{}, - onCollectionBeforeUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{}, - onCollectionAfterUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{}, - onCollectionBeforeDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{}, - onCollectionAfterDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{}, + onCollectionsListRequest: &hook.Hook[*CollectionsListEvent]{}, + onCollectionViewRequest: &hook.Hook[*CollectionViewEvent]{}, + onCollectionBeforeCreateRequest: &hook.Hook[*CollectionCreateEvent]{}, + onCollectionAfterCreateRequest: &hook.Hook[*CollectionCreateEvent]{}, + onCollectionBeforeUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{}, + onCollectionAfterUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{}, + onCollectionBeforeDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{}, + onCollectionAfterDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{}, + onCollectionsBeforeImportRequest: &hook.Hook[*CollectionsImportEvent]{}, + onCollectionsAfterImportRequest: &hook.Hook[*CollectionsImportEvent]{}, } app.registerDefaultHooks() @@ -687,6 +691,14 @@ func (app *BaseApp) OnCollectionAfterDeleteRequest() *hook.Hook[*CollectionDelet return app.onCollectionAfterDeleteRequest } +func (app *BaseApp) OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent] { + return app.onCollectionsBeforeImportRequest +} + +func (app *BaseApp) OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImportEvent] { + return app.onCollectionsAfterImportRequest +} + // ------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------- diff --git a/core/events.go b/core/events.go index 5c3a854e..1d9bd927 100644 --- a/core/events.go +++ b/core/events.go @@ -216,6 +216,11 @@ type CollectionDeleteEvent struct { Collection *models.Collection } +type CollectionsImportEvent struct { + HttpContext echo.Context + Collections []*models.Collection +} + // ------------------------------------------------------------------- // File API events data // ------------------------------------------------------------------- diff --git a/daos/base.go b/daos/base.go index 9c9c1e5a..b0cba86c 100644 --- a/daos/base.go +++ b/daos/base.go @@ -128,11 +128,11 @@ func (dao *Dao) Delete(m models.Model) error { // Save upserts (update or create if primary key is not set) the provided model. func (dao *Dao) Save(m models.Model) error { - if !m.IsNew() { - return dao.update(m) + if m.IsNew() { + return dao.create(m) } - return dao.create(m) + return dao.update(m) } func (dao *Dao) update(m models.Model) error { diff --git a/daos/base_test.go b/daos/base_test.go index 31d12fc9..3d89765e 100644 --- a/daos/base_test.go +++ b/daos/base_test.go @@ -158,6 +158,33 @@ func TestDaoSaveCreate(t *testing.T) { } } +func TestDaoSaveWithInsertId(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + model := &models.Admin{} + model.Id = "test" + model.Email = "test_new@example.com" + model.MarkAsNew() + if err := testApp.Dao().Save(model); err != nil { + t.Fatal(err) + } + + // refresh + model, _ = testApp.Dao().FindAdminById("test") + + if model == nil { + t.Fatal("Failed to find admin with id 'test'") + } + + expectedHooks := []string{"OnModelBeforeCreate", "OnModelAfterCreate"} + for _, h := range expectedHooks { + if v, ok := testApp.EventCalls[h]; !ok || v != 1 { + t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v) + } + } +} + func TestDaoSaveUpdate(t *testing.T) { testApp, _ := tests.NewTestApp() defer testApp.Cleanup() @@ -184,6 +211,61 @@ func TestDaoSaveUpdate(t *testing.T) { } } +type dummyColumnValueMapper struct { + models.Admin +} + +func (a *dummyColumnValueMapper) ColumnValueMap() map[string]any { + return map[string]any{ + "email": a.Email, + "passwordHash": a.PasswordHash, + "tokenKey": "custom_token_key", + } +} + +func TestDaoSaveWithColumnValueMapper(t *testing.T) { + testApp, _ := tests.NewTestApp() + defer testApp.Cleanup() + + model := &dummyColumnValueMapper{} + model.Id = "test_mapped_id" // explicitly set an id + model.Email = "test_mapped_create@example.com" + model.TokenKey = "test_unmapped_token_key" // not used in the map + model.SetPassword("123456") + model.MarkAsNew() + if err := testApp.Dao().Save(model); err != nil { + t.Fatal(err) + } + + createdModel, _ := testApp.Dao().FindAdminById("test_mapped_id") + if createdModel == nil { + t.Fatal("[create] Failed to find model with id 'test_mapped_id'") + } + if createdModel.Email != model.Email { + t.Fatalf("Expected model with email %q, got %q", model.Email, createdModel.Email) + } + if createdModel.TokenKey != "custom_token_key" { + t.Fatalf("Expected model with tokenKey %q, got %q", "custom_token_key", createdModel.TokenKey) + } + + model.Email = "test_mapped_update@example.com" + model.Avatar = 9 // not mapped and expect to be ignored + if err := testApp.Dao().Save(model); err != nil { + t.Fatal(err) + } + + updatedModel, _ := testApp.Dao().FindAdminById("test_mapped_id") + if updatedModel == nil { + t.Fatal("[update] Failed to find model with id 'test_mapped_id'") + } + if updatedModel.Email != model.Email { + t.Fatalf("Expected model with email %q, got %q", model.Email, createdModel.Email) + } + if updatedModel.Avatar != 0 { + t.Fatalf("Expected model avatar 0, got %v", updatedModel.Avatar) + } +} + func TestDaoDelete(t *testing.T) { testApp, _ := tests.NewTestApp() defer testApp.Cleanup() diff --git a/forms/collections_import.go b/forms/collections_import.go index 7c3fe301..195ee93e 100644 --- a/forms/collections_import.go +++ b/forms/collections_import.go @@ -70,11 +70,18 @@ func (form *CollectionsImport) Validate() error { // // All operations are wrapped in a single transaction that are // rollbacked on the first encountered error. -func (form *CollectionsImport) Submit() error { +// +// You can optionally provide a list of InterceptorFunc to further +// modify the form behavior before persisting it. +func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error { if err := form.Validate(); err != nil { return err } + return runInterceptors(form.submit, interceptors...) +} + +func (form *CollectionsImport) submit() error { return form.config.TxDao.RunInTransaction(func(txDao *daos.Dao) error { oldCollections := []*models.Collection{} if err := txDao.CollectionQuery().All(&oldCollections); err != nil { diff --git a/models/base_test.go b/models/base_test.go index 9af48de0..c56e5284 100644 --- a/models/base_test.go +++ b/models/base_test.go @@ -34,25 +34,55 @@ func TestBaseModelHasId(t *testing.T) { } } -func TestBaseModelGetId(t *testing.T) { - m0 := models.BaseModel{} - if m0.GetId() != "" { - t.Fatalf("Expected zero id value, got %v", m0.GetId()) +func TestBaseModelId(t *testing.T) { + m := models.BaseModel{} + + if m.GetId() != "" { + t.Fatalf("Expected empty id value, got %v", m.GetId()) } - id := "abc" - m1 := models.BaseModel{Id: id} - if m1.GetId() != id { - t.Fatalf("Expected id %v, got %v", id, m1.GetId()) + m.SetId("test") + + if m.GetId() != "test" { + t.Fatalf("Expected %q id, got %v", "test", m.GetId()) + } + + m.RefreshId() + + if len(m.GetId()) != 15 { + t.Fatalf("Expected 15 chars id, got %v", m.GetId()) } } -func TestBaseModelRefreshId(t *testing.T) { - m := models.BaseModel{} - m.RefreshId() +func TestBaseModelIsNew(t *testing.T) { + m0 := models.BaseModel{} + m1 := models.BaseModel{Id: ""} + m2 := models.BaseModel{Id: "test"} + m3 := models.BaseModel{} + m3.MarkAsNew() + m4 := models.BaseModel{Id: "test"} + m4.MarkAsNew() + m5 := models.BaseModel{Id: "test"} + m5.MarkAsNew() + m5.UnmarkAsNew() - if m.GetId() == "" { - t.Fatalf("Expected nonempty id value, got %v", m.GetId()) + scenarios := []struct { + model models.BaseModel + expected bool + }{ + {m0, true}, + {m1, true}, + {m2, false}, + {m3, true}, + {m4, true}, + {m5, false}, + } + + for i, s := range scenarios { + result := s.model.IsNew() + if result != s.expected { + t.Errorf("(%d) Expected IsNew %v, got %v", i, s.expected, result) + } } } diff --git a/tests/app.go b/tests/app.go index 435ee1d8..41df9cc9 100644 --- a/tests/app.go +++ b/tests/app.go @@ -313,6 +313,16 @@ func NewTestApp() (*TestApp, error) { return nil }) + t.OnCollectionsBeforeImportRequest().Add(func(e *core.CollectionsImportEvent) error { + t.EventCalls["OnCollectionsBeforeImportRequest"]++ + return nil + }) + + t.OnCollectionsAfterImportRequest().Add(func(e *core.CollectionsImportEvent) error { + t.EventCalls["OnCollectionsAfterImportRequest"]++ + return nil + }) + t.OnAdminsListRequest().Add(func(e *core.AdminsListEvent) error { t.EventCalls["OnAdminsListRequest"]++ return nil diff --git a/ui/.env.development b/ui/.env.development index 657341af..b4615bf4 100644 --- a/ui/.env.development +++ b/ui/.env.development @@ -1 +1 @@ -PB_BACKEND_URL = http://localhost:8090 +PB_BACKEND_URL = http://127.0.0.1:8090 diff --git a/ui/dist/index.html b/ui/dist/index.html index 12e2a416..26aca7c2 100644 --- a/ui/dist/index.html +++ b/ui/dist/index.html @@ -5,7 +5,7 @@