Browse Source

Feature/rule formatting editor (#690)

* UI refactoring

* Code cleanup.

* Minor fixes.

* Tests fixed and unified and code removed.

* Fix bug.

* Update terser to fix compile error.

* Cleanup

* Fix schema forms.

* Form fixes.

* Refactoring.

* Temporary.

* Progress

* Progress

* UI fixes.

* More tests

* Remove refactoring essentials from tests.

* Build fix.
pull/691/head
Sebastian Stehle 5 years ago
committed by GitHub
parent
commit
211bb7f64a
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 31
      backend/Squidex.sln
  2. 39
      backend/i18n/frontend_en.json
  3. 35
      backend/i18n/frontend_it.json
  4. 37
      backend/i18n/frontend_nl.json
  5. 6
      backend/i18n/source/frontend__ignore.json
  6. 39
      backend/i18n/source/frontend_en.json
  7. 6
      backend/i18n/source/frontend_it.json
  8. 8
      backend/i18n/source/frontend_nl.json
  9. 8
      backend/i18n/translate_en.bat
  10. 21
      backend/i18n/translator/Squidex.Translator/Commands.cs
  11. 2
      backend/i18n/translator/Squidex.Translator/Processes/Helper.cs
  12. 6
      backend/src/Migrations/Migrations/CreateAppSettings.cs
  13. 18
      backend/src/Migrations/Migrations/PopulateGrainIndexes.cs
  14. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs
  15. 12
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs
  16. 34
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs
  17. 26
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/JobResult.cs
  18. 23
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleContext.cs
  19. 144
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs
  20. 96
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleTriggerHandler.cs
  21. 23
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/SkipReason.cs
  22. 1
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  23. 8
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  24. 4
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  25. 4
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  26. 8
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs
  27. 7
      backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs
  28. 43
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs
  29. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/RepairFiles.cs
  30. 3
      backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs
  31. 4
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs
  32. 33
      backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs
  33. 59
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs
  34. 3
      backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs
  35. 2
      backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs
  36. 5
      backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs
  37. 30
      backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs
  38. 16
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
  39. 128
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs
  40. 61
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/GrainRuleRunnerService.cs
  41. 4
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs
  42. 47
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerGrain.cs
  43. 22
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/SimulatedRuleEvent.cs
  44. 34
      backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTriggerHandler.cs
  45. 32
      backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs
  46. 134
      backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs
  47. 8
      backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Writer.cs
  48. 46
      backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterBuilder.cs
  49. 62
      backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterExtensions.cs
  50. 153
      backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs
  51. 40
      backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/ProjectionClient.cs
  52. 4
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/FilterExtensions.cs
  53. 6
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs
  54. 115
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs
  55. 36
      backend/src/Squidex.Infrastructure/CollectionExtensions.cs
  56. 4
      backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs
  57. 24
      backend/src/Squidex.Infrastructure/EventSourcing/EventData.cs
  58. 8
      backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs
  59. 4
      backend/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs
  60. 25
      backend/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs
  61. 2
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  62. 2
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs
  63. 49
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventDto.cs
  64. 42
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventsDto.cs
  65. 7
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerDto.cs
  66. 12
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerSchemaDto.cs
  67. 38
      backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  68. 1
      backend/src/Squidex/Areas/Api/Controllers/UI/MyUIOptions.cs
  69. 44
      backend/src/Squidex/Config/Domain/EventSourcingServices.cs
  70. 2
      backend/src/Squidex/Config/Domain/RuleServices.cs
  71. 3
      backend/src/Squidex/Squidex.csproj
  72. 36
      backend/src/Squidex/appsettings.json
  73. 391
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs
  74. 1
      backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj
  75. 124
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs
  76. 21
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RepairFilesTests.cs
  77. 173
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs
  78. 162
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs
  79. 31
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/ManualTriggerHandlerTests.cs
  80. 46
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs
  81. 61
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/UsageTracking/UsageTriggerHandlerTests.cs
  82. 86
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs
  83. 1
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj
  84. 1
      backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj
  85. 36
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreFixture.cs
  86. 31
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreTests.cs
  87. 112
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs
  88. 64
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreFixture.cs
  89. 31
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreTests.cs
  90. 10
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/PollingSubscriptionTests.cs
  91. 3
      backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj
  92. 1
      backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj
  93. 1
      backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj
  94. 1
      backend/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj
  95. 6
      frontend/app/features/administration/pages/users/users-page.component.html
  96. 2
      frontend/app/features/apps/pages/apps-page.component.html
  97. 2
      frontend/app/features/content/pages/content/content-history-page.component.html
  98. 5
      frontend/app/features/content/shared/forms/assets-editor.component.ts
  99. 9
      frontend/app/features/content/shared/forms/iframe-editor.component.ts
  100. 11
      frontend/app/features/content/shared/forms/stock-photo-editor.component.ts

31
backend/Squidex.sln

@ -30,12 +30,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Users.MongoD
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Users.Tests", "tests\Squidex.Domain.Users.Tests\Squidex.Domain.Users.Tests.csproj", "{42184546-E3CB-4D4F-9495-43979B9C63B9}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Domain.Users.Tests", "tests\Squidex.Domain.Users.Tests\Squidex.Domain.Users.Tests.csproj", "{42184546-E3CB-4D4F-9495-43979B9C63B9}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Infrastructure.GetEventStore", "src\Squidex.Infrastructure.GetEventStore\Squidex.Infrastructure.GetEventStore.csproj", "{EF75E488-1324-4E18-A1BD-D3A05AE67B1F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Squidex.Infrastructure.Azure", "src\Squidex.Infrastructure.Azure\Squidex.Infrastructure.Azure.csproj", "{7931187E-A1E6-4F89-8BC8-20A1E445579F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{360C300D-0F7E-439D-A437-714C959E3CAD}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{360C300D-0F7E-439D-A437-714C959E3CAD}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
Squidex.ruleset = Squidex.ruleset
stylecop.json = stylecop.json stylecop.json = stylecop.json
EndProjectSection EndProjectSection
EndProject EndProject
@ -179,30 +176,6 @@ Global
{42184546-E3CB-4D4F-9495-43979B9C63B9}.Release|x64.Build.0 = Release|Any CPU {42184546-E3CB-4D4F-9495-43979B9C63B9}.Release|x64.Build.0 = Release|Any CPU
{42184546-E3CB-4D4F-9495-43979B9C63B9}.Release|x86.ActiveCfg = Release|Any CPU {42184546-E3CB-4D4F-9495-43979B9C63B9}.Release|x86.ActiveCfg = Release|Any CPU
{42184546-E3CB-4D4F-9495-43979B9C63B9}.Release|x86.Build.0 = Release|Any CPU {42184546-E3CB-4D4F-9495-43979B9C63B9}.Release|x86.Build.0 = Release|Any CPU
{EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Debug|x64.ActiveCfg = Debug|Any CPU
{EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Debug|x64.Build.0 = Debug|Any CPU
{EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Debug|x86.ActiveCfg = Debug|Any CPU
{EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Debug|x86.Build.0 = Debug|Any CPU
{EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Release|Any CPU.Build.0 = Release|Any CPU
{EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Release|x64.ActiveCfg = Release|Any CPU
{EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Release|x64.Build.0 = Release|Any CPU
{EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Release|x86.ActiveCfg = Release|Any CPU
{EF75E488-1324-4E18-A1BD-D3A05AE67B1F}.Release|x86.Build.0 = Release|Any CPU
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Debug|x64.ActiveCfg = Debug|Any CPU
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Debug|x64.Build.0 = Debug|Any CPU
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Debug|x86.ActiveCfg = Debug|Any CPU
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Debug|x86.Build.0 = Debug|Any CPU
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|Any CPU.Build.0 = Release|Any CPU
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|x64.ActiveCfg = Release|Any CPU
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|x64.Build.0 = Release|Any CPU
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|x86.ActiveCfg = Release|Any CPU
{7931187E-A1E6-4F89-8BC8-20A1E445579F}.Release|x86.Build.0 = Release|Any CPU
{F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|Any CPU.Build.0 = Debug|Any CPU {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|x64.ActiveCfg = Debug|Any CPU {F0A83301-50A5-40EA-A1A2-07C7858F5A3F}.Debug|x64.ActiveCfg = Debug|Any CPU
@ -326,8 +299,6 @@ Global
{F7771E22-47BD-45C4-A133-FD7F1DE27CA0} = {7EDE8CF1-B1E4-4005-B154-834B944E0D7A} {F7771E22-47BD-45C4-A133-FD7F1DE27CA0} = {7EDE8CF1-B1E4-4005-B154-834B944E0D7A}
{27CF800D-890F-4882-BF05-44EC3233537D} = {7EDE8CF1-B1E4-4005-B154-834B944E0D7A} {27CF800D-890F-4882-BF05-44EC3233537D} = {7EDE8CF1-B1E4-4005-B154-834B944E0D7A}
{42184546-E3CB-4D4F-9495-43979B9C63B9} = {7EDE8CF1-B1E4-4005-B154-834B944E0D7A} {42184546-E3CB-4D4F-9495-43979B9C63B9} = {7EDE8CF1-B1E4-4005-B154-834B944E0D7A}
{EF75E488-1324-4E18-A1BD-D3A05AE67B1F} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF}
{7931187E-A1E6-4F89-8BC8-20A1E445579F} = {8CF53B92-5EB1-461D-98F8-70DA9B603FBF}
{F0A83301-50A5-40EA-A1A2-07C7858F5A3F} = {4C6B06C2-6D77-4E0E-AE32-D7050236433A} {F0A83301-50A5-40EA-A1A2-07C7858F5A3F} = {4C6B06C2-6D77-4E0E-AE32-D7050236433A}
{6B3F75B6-5888-468E-BA4F-4FC725DAEF31} = {4C6B06C2-6D77-4E0E-AE32-D7050236433A} {6B3F75B6-5888-468E-BA4F-4FC725DAEF31} = {4C6B06C2-6D77-4E0E-AE32-D7050236433A}
{79FEF326-CA5E-4698-B2BA-C16A4580B4D5} = {4C6B06C2-6D77-4E0E-AE32-D7050236433A} {79FEF326-CA5E-4698-B2BA-C16A4580B4D5} = {4C6B06C2-6D77-4E0E-AE32-D7050236433A}

39
backend/i18n/frontend_en.json

@ -41,9 +41,11 @@
"apps.leaveFailed": "Failed to leave app. Please reload.", "apps.leaveFailed": "Failed to leave app. Please reload.",
"apps.listPageTitle": "Apps", "apps.listPageTitle": "Apps",
"apps.loadFailed": "Failed to load apps. Please reload.", "apps.loadFailed": "Failed to load apps. Please reload.",
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Remove image", "apps.removeImage": "Remove image",
"apps.removeImageFailed": "Failed to remove app image. Please reload.", "apps.removeImageFailed": "Failed to remove app image. Please reload.",
"apps.updateFailed": "Failed to update app. Please reload.", "apps.updateFailed": "Failed to update app. Please reload.",
"apps.updateSettingsFailed": "Faield to update UI settings. Please reload.",
"apps.upgradeHintCurrent": "You are on the {plan} plan.", "apps.upgradeHintCurrent": "You are on the {plan} plan.",
"apps.upgradeHintUpgrade": "Upgrade!", "apps.upgradeHintUpgrade": "Upgrade!",
"apps.uploadImage": "Drop an file to replace the app image. Use a square size.", "apps.uploadImage": "Drop an file to replace the app image. Use a square size.",
@ -52,11 +54,18 @@
"apps.uploadImageTooBig": "App image is too big.", "apps.uploadImageTooBig": "App image is too big.",
"apps.welcomeSubtitle": "Welcome to Squidex.", "apps.welcomeSubtitle": "Welcome to Squidex.",
"apps.welcomeTitle": "Hi {user}", "apps.welcomeTitle": "Hi {user}",
"appSettings.editors.deleteConfirmText": "Do you really want to remove this Editor URL?",
"appSettings.editors.deleteConfirmTitle": "Delete Editor URL",
"appSettings.editors.empty": "No Editor URL created yet.",
"appSettings.editors.title": "Custom Editors",
"appSettings.hideScheduler": "Hide dialog for scheduled publishing", "appSettings.hideScheduler": "Hide dialog for scheduled publishing",
"appSettings.patterns.deleteConfirmText": "Do you really want to remove this pattern?",
"appSettings.patterns.deleteConfirmTitle": "Delete pattern",
"appSettings.patterns.empty": "No pattern created yet.",
"appSettings.patterns.title": "Patterns",
"appSettings.refreshTooltip": "Refresh UI Settings (CTRL + SHIFT + R)", "appSettings.refreshTooltip": "Refresh UI Settings (CTRL + SHIFT + R)",
"appSettings.reloaded": "UI Settings reloaded.", "appSettings.reloaded": "UI Settings reloaded.",
"appSettings.title": "UI Settings", "appSettings.title": "UI Settings",
"appSettings.updateFailed": "Failed to update UI settings. Please reload.",
"assets.createFolder": "Create Folder", "assets.createFolder": "Create Folder",
"assets.createFolderFailed": "Failed to create asset folder. Please reload.", "assets.createFolderFailed": "Failed to create asset folder. Please reload.",
"assets.createFolderTooltip": "Create new folder (CTRL + SHIFT + G)", "assets.createFolderTooltip": "Create new folder (CTRL + SHIFT + G)",
@ -331,6 +340,7 @@
"common.settings": "Settings", "common.settings": "Settings",
"common.sidebar": "Sidebar Extension", "common.sidebar": "Sidebar Extension",
"common.sidebarTour": "The sidebar navigation contains useful context specific links. Here you can view the history how this schema has changed over time.", "common.sidebarTour": "The sidebar navigation contains useful context specific links. Here you can view the history how this schema has changed over time.",
"common.skipped": "Skipped",
"common.slug": "Slug", "common.slug": "Slug",
"common.stars.max": "Must not have more more than 15 stars", "common.stars.max": "Must not have more more than 15 stars",
"common.status": "Status", "common.status": "Status",
@ -528,10 +538,6 @@
"dashboard.trafficSummaryCard": "API Traffic Summary", "dashboard.trafficSummaryCard": "API Traffic Summary",
"dashboard.welcomeText": "Welcome to **{app}** dashboard.", "dashboard.welcomeText": "Welcome to **{app}** dashboard.",
"dashboard.welcomeTitle": "Hi {user}", "dashboard.welcomeTitle": "Hi {user}",
"editors.deleteConfirmText": "Do you really want to remove this Editor URL?",
"editors.deleteConfirmTitle": "Delete Editor URL",
"editors.empty": "No Editor URL created yet.",
"editors.title": "Custom Editors",
"eventConsumers.count": "Count", "eventConsumers.count": "Count",
"eventConsumers.loadFailed": "Failed to load event consumers. Please reload.", "eventConsumers.loadFailed": "Failed to load event consumers. Please reload.",
"eventConsumers.pageTitle": "Event Consumers", "eventConsumers.pageTitle": "Event Consumers",
@ -563,10 +569,6 @@
"news.headline": "What's new?", "news.headline": "What's new?",
"news.title": "New Features", "news.title": "New Features",
"notifo.subscripeTooltip": "Click this button to subscribe to all changes and to receive push notifications.", "notifo.subscripeTooltip": "Click this button to subscribe to all changes and to receive push notifications.",
"patterns.deleteConfirmText": "Do you really want to remove this pattern?",
"patterns.deleteConfirmTitle": "Delete pattern",
"patterns.empty": "No pattern created yet.",
"patterns.title": "Patterns",
"plans.billingPortal": "Billing Portal", "plans.billingPortal": "Billing Portal",
"plans.billingPortalHint": "Go to Billing Portal for payment history and subscription overview.", "plans.billingPortalHint": "Go to Billing Portal for payment history and subscription overview.",
"plans.change": "Change", "plans.change": "Change",
@ -590,7 +592,7 @@
"roles.addFailed": "Failed to add role. Please reload.", "roles.addFailed": "Failed to add role. Please reload.",
"roles.default.owner": "Can do everything, including deleting the app.", "roles.default.owner": "Can do everything, including deleting the app.",
"roles.default.reader": "Can only read assets and contents.", "roles.default.reader": "Can only read assets and contents.",
"roles.defaults.developer": "Can use the API view, edit assets, contents, schemas, rules, workflows and patterns.", "roles.defaults.developer": "Can use the API view, edit assets, contents, schemas, rules, workflows and appSettings.patterns.",
"roles.defaults.editor": "Can edit assets and contents and view workflows.", "roles.defaults.editor": "Can edit assets and contents and view workflows.",
"roles.deleteConfirmText": "Delete role", "roles.deleteConfirmText": "Delete role",
"roles.deleteConfirmTitle": "Do you really want to delete the role?", "roles.deleteConfirmTitle": "Do you really want to delete the role?",
@ -611,7 +613,8 @@
"roles.revokeFailed": "Failed to revoke role. Please reload.", "roles.revokeFailed": "Failed to revoke role. Please reload.",
"roles.roleNamePlaceholder": "Enter role name", "roles.roleNamePlaceholder": "Enter role name",
"roles.updateFailed": "Failed to update role. Please reload.", "roles.updateFailed": "Failed to update role. Please reload.",
"rules.actionEdit": "Edit Action", "rules.actionData": "Action Data",
"rules.actionHint": "The selection of the action type cannot be changed later.",
"rules.cancelFailed": "Failed to cancel rule. Please reload.", "rules.cancelFailed": "Failed to cancel rule. Please reload.",
"rules.create": "New Rule", "rules.create": "New Rule",
"rules.createFailed": "Failed to create rule. Please reload.", "rules.createFailed": "Failed to create rule. Please reload.",
@ -619,10 +622,8 @@
"rules.deleteConfirmText": "Do you really want to delete the rule?", "rules.deleteConfirmText": "Do you really want to delete the rule?",
"rules.deleteConfirmTitle": "Delete rule", "rules.deleteConfirmTitle": "Delete rule",
"rules.deleteFailed": "Failed to delete rule. Please reload.", "rules.deleteFailed": "Failed to delete rule. Please reload.",
"rules.disableFailed": "Failed to disable rule. Please reload.",
"rules.empty": "No rule created yet.", "rules.empty": "No rule created yet.",
"rules.emptyAddRule": "Add Rule", "rules.emptyAddRule": "Add Rule",
"rules.enableFailed": "Failed to enable rule. Please reload.",
"rules.enqueued": "Rule has been added to the queue.", "rules.enqueued": "Rule has been added to the queue.",
"rules.itemPageTitle": "Rule", "rules.itemPageTitle": "Rule",
"rules.listPageTitle": "Rules", "rules.listPageTitle": "Rules",
@ -642,6 +643,7 @@
"rules.ruleEvents.nextAttemptLabel": "Next", "rules.ruleEvents.nextAttemptLabel": "Next",
"rules.ruleEvents.numAttemptsLabel": "Attempts", "rules.ruleEvents.numAttemptsLabel": "Attempts",
"rules.ruleEvents.reloaded": "RuleEvents reloaded.", "rules.ruleEvents.reloaded": "RuleEvents reloaded.",
"rules.ruleSimulator.listPageTitle": "Simulator",
"rules.ruleSyntax.if": "If", "rules.ruleSyntax.if": "If",
"rules.ruleSyntax.then": "then", "rules.ruleSyntax.then": "then",
"rules.run": "Run", "rules.run": "Run",
@ -650,17 +652,16 @@
"rules.runningRule": "Rule '{name}' is currently running.", "rules.runningRule": "Rule '{name}' is currently running.",
"rules.runRuleConfirmText": "Do you really want to run the rule for all events?", "rules.runRuleConfirmText": "Do you really want to run the rule for all events?",
"rules.runRuleConfirmTitle": "Run rule", "rules.runRuleConfirmTitle": "Run rule",
"rules.simulate": "Simulate",
"rules.simulateTooltip": "Simulate this rules using the last 100 events.",
"rules.simulator": "Simulator",
"rules.stop": "Rule will stop soon.", "rules.stop": "Rule will stop soon.",
"rules.triggerConfirmText": "Do you really want to trigger the rule?", "rules.triggerConfirmText": "Do you really want to trigger the rule?",
"rules.triggerConfirmTitle": "Trigger rule", "rules.triggerConfirmTitle": "Trigger rule",
"rules.triggerEdit": "Edit Trigger",
"rules.triggerFailed": "Failed to trigger rule. Please reload.", "rules.triggerFailed": "Failed to trigger rule. Please reload.",
"rules.triggerHint": "The selection of the trigger type cannot be changed later.",
"rules.unnamed": "Unnamed Rule", "rules.unnamed": "Unnamed Rule",
"rules.updateFailed": "Failed to update rule. Please reload.", "rules.updateFailed": "Failed to update rule. Please reload.",
"rules.wizard.actionHint": "The selection of the action type cannot be changed later.",
"rules.wizard.selectAction": "Select Action",
"rules.wizard.selectTrigger": "Select Trigger",
"rules.wizard.triggerHint": "The selection of the trigger type cannot be changed later.",
"schemas.addField": "Add Field", "schemas.addField": "Add Field",
"schemas.addFieldAndClose": "Create and close", "schemas.addFieldAndClose": "Create and close",
"schemas.addFieldAndCreate": "Create and add field", "schemas.addFieldAndCreate": "Create and add field",
@ -883,7 +884,7 @@
"tour.step1Next": "Continue", "tour.step1Next": "Continue",
"tour.step1Text": "An App is the repository for your project, e.g. (blog, web shop or mobile app). You can assign contributors to your app to work together.\n\nYou can create an unlimited number of Apps in Squidex to manage multiple projects at the same time.", "tour.step1Text": "An App is the repository for your project, e.g. (blog, web shop or mobile app). You can assign contributors to your app to work together.\n\nYou can create an unlimited number of Apps in Squidex to manage multiple projects at the same time.",
"tour.step2Next": "Keep going!", "tour.step2Next": "Keep going!",
"tour.step2Text": "Schemas define the structure of your content, the fields and the data types of a content item.\n\nBefore you can add content to your schema, make sure to hit the 'Publish' button at the top to make the schema available to your content editors.", "tour.step2Text": "Schemas define the structure of your content, the fields and the data types of a content item.\n\nBefore you can add content to your schema, make sure to hit the 'Publish' button at the top to make the schema available to your content appSettings.editors.",
"tour.step3Next": "Almost there!", "tour.step3Next": "Almost there!",
"tour.step3Text": "Content is the actual data in your app which is grouped by the schema.\n\nSelect a published schema first, then add content for this schema.", "tour.step3Text": "Content is the actual data in your app which is grouped by the schema.\n\nSelect a published schema first, then add content for this schema.",
"tour.step4Next": "Got It!", "tour.step4Next": "Got It!",

35
backend/i18n/frontend_it.json

@ -41,9 +41,11 @@
"apps.leaveFailed": "Non è stato possibile uscire dall'app. Per favore ricarica.", "apps.leaveFailed": "Non è stato possibile uscire dall'app. Per favore ricarica.",
"apps.listPageTitle": "App", "apps.listPageTitle": "App",
"apps.loadFailed": "Non è stato possibile caricare le App. Per favore ricarica.", "apps.loadFailed": "Non è stato possibile caricare le App. Per favore ricarica.",
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Rimuovi l'immagine", "apps.removeImage": "Rimuovi l'immagine",
"apps.removeImageFailed": "Non è stato possibile rimuovere l'immagine dell'app. Per favore ricarica.", "apps.removeImageFailed": "Non è stato possibile rimuovere l'immagine dell'app. Per favore ricarica.",
"apps.updateFailed": "Non è stato possibile aggiornare l'app. Per favore ricarica.", "apps.updateFailed": "Non è stato possibile aggiornare l'app. Per favore ricarica.",
"apps.updateSettingsFailed": "Faield to update UI settings. Please reload.",
"apps.upgradeHintCurrent": "Tu sei nel piano {plan}.", "apps.upgradeHintCurrent": "Tu sei nel piano {plan}.",
"apps.upgradeHintUpgrade": "Aggiorna!", "apps.upgradeHintUpgrade": "Aggiorna!",
"apps.uploadImage": "Trascina il file per sostituire l'immagine dell'app. Utilizza una dimensione quadrata.", "apps.uploadImage": "Trascina il file per sostituire l'immagine dell'app. Utilizza una dimensione quadrata.",
@ -52,11 +54,18 @@
"apps.uploadImageTooBig": "L'immagine dell'app è troppo grande.", "apps.uploadImageTooBig": "L'immagine dell'app è troppo grande.",
"apps.welcomeSubtitle": "Benvenuto su Squidex.", "apps.welcomeSubtitle": "Benvenuto su Squidex.",
"apps.welcomeTitle": "Ciao {user}", "apps.welcomeTitle": "Ciao {user}",
"appSettings.editors.deleteConfirmText": "Do you really want to remove this Editor URL?",
"appSettings.editors.deleteConfirmTitle": "Delete Editor URL",
"appSettings.editors.empty": "No Editor URL created yet.",
"appSettings.editors.title": "Custom Editors",
"appSettings.hideScheduler": "Hide dialog for scheduled publishing", "appSettings.hideScheduler": "Hide dialog for scheduled publishing",
"appSettings.patterns.deleteConfirmText": "Sei sicuro di voler rimuovere il pattern?",
"appSettings.patterns.deleteConfirmTitle": "Cancella il pattern",
"appSettings.patterns.empty": "Nessun pattern è stato ancora creato.",
"appSettings.patterns.title": "Patterns",
"appSettings.refreshTooltip": "Refresh UI Settings (CTRL + SHIFT + R)", "appSettings.refreshTooltip": "Refresh UI Settings (CTRL + SHIFT + R)",
"appSettings.reloaded": "UI Settings reloaded.", "appSettings.reloaded": "UI Settings reloaded.",
"appSettings.title": "UI Settings", "appSettings.title": "UI Settings",
"appSettings.updateFailed": "Failed to update UI settings. Please reload.",
"assets.createFolder": "Crea cartella", "assets.createFolder": "Crea cartella",
"assets.createFolderFailed": "Non è stato possibile creare la cartella degli asset. Per favore ricarica.", "assets.createFolderFailed": "Non è stato possibile creare la cartella degli asset. Per favore ricarica.",
"assets.createFolderTooltip": "Crea una nuova cartella (CTRL + SHIFT + G)", "assets.createFolderTooltip": "Crea una nuova cartella (CTRL + SHIFT + G)",
@ -331,6 +340,7 @@
"common.settings": "Impostazioni", "common.settings": "Impostazioni",
"common.sidebar": "Estensione della barra di navigazione laterale", "common.sidebar": "Estensione della barra di navigazione laterale",
"common.sidebarTour": "La barra di navigazione laterale contiene specifici utili collegamenti per il contesto. Qui puoi visualizzare la cronologia dei cambiamenti di questo schema.", "common.sidebarTour": "La barra di navigazione laterale contiene specifici utili collegamenti per il contesto. Qui puoi visualizzare la cronologia dei cambiamenti di questo schema.",
"common.skipped": "Skipped",
"common.slug": "Slug", "common.slug": "Slug",
"common.stars.max": "Non deve avere più di 15 stelle", "common.stars.max": "Non deve avere più di 15 stelle",
"common.status": "Stato", "common.status": "Stato",
@ -528,10 +538,6 @@
"dashboard.trafficSummaryCard": "Riepilogo del traffico delle API", "dashboard.trafficSummaryCard": "Riepilogo del traffico delle API",
"dashboard.welcomeText": "Benvenuto sulla dashboard **{app}**.", "dashboard.welcomeText": "Benvenuto sulla dashboard **{app}**.",
"dashboard.welcomeTitle": "Ciao {user}", "dashboard.welcomeTitle": "Ciao {user}",
"editors.deleteConfirmText": "Do you really want to remove this Editor URL?",
"editors.deleteConfirmTitle": "Delete Editor URL",
"editors.empty": "No Editor URL created yet.",
"editors.title": "Custom Editors",
"eventConsumers.count": "Conteggio", "eventConsumers.count": "Conteggio",
"eventConsumers.loadFailed": "Non è stato possibile caricare event consumers. Per favore ricarica.", "eventConsumers.loadFailed": "Non è stato possibile caricare event consumers. Per favore ricarica.",
"eventConsumers.pageTitle": "Eventi degli utenti", "eventConsumers.pageTitle": "Eventi degli utenti",
@ -563,10 +569,6 @@
"news.headline": "Che cosa c'è di nuovo?", "news.headline": "Che cosa c'è di nuovo?",
"news.title": "Nuove funzionalità", "news.title": "Nuove funzionalità",
"notifo.subscripeTooltip": "Fai clic su questo pulsante per iscriverti a tutte le modifiche e ricevere le notifiche push.", "notifo.subscripeTooltip": "Fai clic su questo pulsante per iscriverti a tutte le modifiche e ricevere le notifiche push.",
"patterns.deleteConfirmText": "Sei sicuro di voler rimuovere il pattern?",
"patterns.deleteConfirmTitle": "Cancella il pattern",
"patterns.empty": "Nessun pattern è stato ancora creato.",
"patterns.title": "Patterns",
"plans.billingPortal": "Portale di fatturazione", "plans.billingPortal": "Portale di fatturazione",
"plans.billingPortalHint": "Vai al portale di fatturazione per lo storico dei pagamenti e una panoramica per l'abbonamento.", "plans.billingPortalHint": "Vai al portale di fatturazione per lo storico dei pagamenti e una panoramica per l'abbonamento.",
"plans.change": "Cambia", "plans.change": "Cambia",
@ -611,7 +613,8 @@
"roles.revokeFailed": "Non è stato possibile rimuovere il ruolo. Per favore ricarica.", "roles.revokeFailed": "Non è stato possibile rimuovere il ruolo. Per favore ricarica.",
"roles.roleNamePlaceholder": "Inserisci il nome del ruolo", "roles.roleNamePlaceholder": "Inserisci il nome del ruolo",
"roles.updateFailed": "Non è stato possibile aggiornare il ruolo. Per favore ricarica.", "roles.updateFailed": "Non è stato possibile aggiornare il ruolo. Per favore ricarica.",
"rules.actionEdit": "Modifica l'Azione", "rules.actionData": "Action Data",
"rules.actionHint": "The selection of the action type cannot be changed later.",
"rules.cancelFailed": "Non è stato possibile eliminare la regola. Per favore ricarica.", "rules.cancelFailed": "Non è stato possibile eliminare la regola. Per favore ricarica.",
"rules.create": "Crea un nuova Regola", "rules.create": "Crea un nuova Regola",
"rules.createFailed": "Non è stato possibile creare una nuova regola. Per favore ricarica.", "rules.createFailed": "Non è stato possibile creare una nuova regola. Per favore ricarica.",
@ -619,10 +622,8 @@
"rules.deleteConfirmText": "Sei sicuro di voler eliminare la regola?", "rules.deleteConfirmText": "Sei sicuro di voler eliminare la regola?",
"rules.deleteConfirmTitle": "Cancella la regola", "rules.deleteConfirmTitle": "Cancella la regola",
"rules.deleteFailed": "Non è stato possibile eliminare la regola. Per favore ricarica.", "rules.deleteFailed": "Non è stato possibile eliminare la regola. Per favore ricarica.",
"rules.disableFailed": "Non è stato possibile disabilitare la regola. Per favore ricarica.",
"rules.empty": "Nessuna regola è stato ancora creata.", "rules.empty": "Nessuna regola è stato ancora creata.",
"rules.emptyAddRule": "Aggiungi una regola", "rules.emptyAddRule": "Aggiungi una regola",
"rules.enableFailed": "Non è stato possibile abilitare la regola. Per favore ricarica.",
"rules.enqueued": "La regola è stata aggiunta alle code.", "rules.enqueued": "La regola è stata aggiunta alle code.",
"rules.itemPageTitle": "Rule", "rules.itemPageTitle": "Rule",
"rules.listPageTitle": "Regole", "rules.listPageTitle": "Regole",
@ -642,6 +643,7 @@
"rules.ruleEvents.nextAttemptLabel": "Successivo", "rules.ruleEvents.nextAttemptLabel": "Successivo",
"rules.ruleEvents.numAttemptsLabel": "Tentativi", "rules.ruleEvents.numAttemptsLabel": "Tentativi",
"rules.ruleEvents.reloaded": "Eventi della regola ricaricati.", "rules.ruleEvents.reloaded": "Eventi della regola ricaricati.",
"rules.ruleSimulator.listPageTitle": "Simulator",
"rules.ruleSyntax.if": "Se", "rules.ruleSyntax.if": "Se",
"rules.ruleSyntax.then": "Allora", "rules.ruleSyntax.then": "Allora",
"rules.run": "Esegui", "rules.run": "Esegui",
@ -650,17 +652,16 @@
"rules.runningRule": "La regola '{name}' è attualmente in esecuzione.", "rules.runningRule": "La regola '{name}' è attualmente in esecuzione.",
"rules.runRuleConfirmText": "Sei sicuro di voler eseguire la regola per tutti gli eventi?", "rules.runRuleConfirmText": "Sei sicuro di voler eseguire la regola per tutti gli eventi?",
"rules.runRuleConfirmTitle": "Esegui la regola", "rules.runRuleConfirmTitle": "Esegui la regola",
"rules.simulate": "Simulate",
"rules.simulateTooltip": "Simulate this rules using the last 100 events.",
"rules.simulator": "Simulator",
"rules.stop": "La regola si fermerà al più presto.", "rules.stop": "La regola si fermerà al più presto.",
"rules.triggerConfirmText": "Sei sicuro che voler attivare la regola?", "rules.triggerConfirmText": "Sei sicuro che voler attivare la regola?",
"rules.triggerConfirmTitle": "Attiva la regola", "rules.triggerConfirmTitle": "Attiva la regola",
"rules.triggerEdit": "Modifica l'Attivazione",
"rules.triggerFailed": "Non è stato possibile attivare la regola. Per favore ricarica.", "rules.triggerFailed": "Non è stato possibile attivare la regola. Per favore ricarica.",
"rules.triggerHint": "The selection of the trigger type cannot be changed later.",
"rules.unnamed": "Regola senza nome", "rules.unnamed": "Regola senza nome",
"rules.updateFailed": "Non è stato possibile aggiornare la regola. Per favore ricarica.", "rules.updateFailed": "Non è stato possibile aggiornare la regola. Per favore ricarica.",
"rules.wizard.actionHint": "La selezione del tipo di azione non potrà essere modificata successivamente.",
"rules.wizard.selectAction": "Seleziona l'Azione",
"rules.wizard.selectTrigger": "Seleziona l'Attivazione",
"rules.wizard.triggerHint": "La selezione del tipo di attivazione non potrà essere modificata successivamente.",
"schemas.addField": "Aggiungi un Campo", "schemas.addField": "Aggiungi un Campo",
"schemas.addFieldAndClose": "Crea e chiudi", "schemas.addFieldAndClose": "Crea e chiudi",
"schemas.addFieldAndCreate": "Crea e aggiungi il campo", "schemas.addFieldAndCreate": "Crea e aggiungi il campo",

37
backend/i18n/frontend_nl.json

@ -41,9 +41,11 @@
"apps.leaveFailed": "Failed to leave app. Please reload.", "apps.leaveFailed": "Failed to leave app. Please reload.",
"apps.listPageTitle": "Apps", "apps.listPageTitle": "Apps",
"apps.loadFailed": "Laden van apps is mislukt. Laad opnieuw.", "apps.loadFailed": "Laden van apps is mislukt. Laad opnieuw.",
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Afbeelding verwijderen", "apps.removeImage": "Afbeelding verwijderen",
"apps.removeImageFailed": "Verwijderen van app-afbeelding is mislukt. Laad opnieuw.", "apps.removeImageFailed": "Verwijderen van app-afbeelding is mislukt. Laad opnieuw.",
"apps.updateFailed": "Update app mislukt. Laad opnieuw.", "apps.updateFailed": "Update app mislukt. Laad opnieuw.",
"apps.updateSettingsFailed": "Faield to update UI settings. Please reload.",
"apps.upgradeHintCurrent": "Je zit in het plan {plan}.", "apps.upgradeHintCurrent": "Je zit in het plan {plan}.",
"apps.upgradeHintUpgrade": "Upgrade!", "apps.upgradeHintUpgrade": "Upgrade!",
"apps.uploadImage": "Zet een bestand neer om de app-afbeelding te vervangen. Gebruik een vierkant formaat.", "apps.uploadImage": "Zet een bestand neer om de app-afbeelding te vervangen. Gebruik een vierkant formaat.",
@ -52,11 +54,18 @@
"apps.uploadImageTooBig": "App-afbeelding is te groot.", "apps.uploadImageTooBig": "App-afbeelding is te groot.",
"apps.welcomeSubtitle": "Welkom bij Squidex.", "apps.welcomeSubtitle": "Welkom bij Squidex.",
"apps.welcomeTitle": "Hallo {user}", "apps.welcomeTitle": "Hallo {user}",
"appSettings.editors.deleteConfirmText": "Do you really want to remove this Editor URL?",
"appSettings.editors.deleteConfirmTitle": "Delete Editor URL",
"appSettings.editors.empty": "No Editor URL created yet.",
"appSettings.editors.title": "Custom Editors",
"appSettings.hideScheduler": "Hide dialog for scheduled publishing", "appSettings.hideScheduler": "Hide dialog for scheduled publishing",
"appSettings.patterns.deleteConfirmText": "Wil je dit patroon echt verwijderen?",
"appSettings.patterns.deleteConfirmTitle": "Verwijder patroon",
"appSettings.patterns.empty": "Nog geen patroon gemaakt.",
"appSettings.patterns.title": "Patterns",
"appSettings.refreshTooltip": "Refresh UI Settings (CTRL + SHIFT + R)", "appSettings.refreshTooltip": "Refresh UI Settings (CTRL + SHIFT + R)",
"appSettings.reloaded": "UI Settings reloaded.", "appSettings.reloaded": "UI Settings reloaded.",
"appSettings.title": "UI Settings", "appSettings.title": "UI Settings",
"appSettings.updateFailed": "Failed to update UI settings. Please reload.",
"assets.createFolder": "Map maken", "assets.createFolder": "Map maken",
"assets.createFolderFailed": "Maken van een map is mislukt. Laad opnieuw.", "assets.createFolderFailed": "Maken van een map is mislukt. Laad opnieuw.",
"assets.createFolderTooltip": "Nieuwe map maken (CTRL + SHIFT + G)", "assets.createFolderTooltip": "Nieuwe map maken (CTRL + SHIFT + G)",
@ -331,6 +340,7 @@
"common.settings": "Instellingen", "common.settings": "Instellingen",
"common.sidebar": "Zijbalk Uitbreiding", "common.sidebar": "Zijbalk Uitbreiding",
"common.sidebarTour": "De zijbalknavigatie bevat nuttige contextspecifieke links. Hier kun je de geschiedenis bekijken hoe dit schema in de loop van de tijd is veranderd.", "common.sidebarTour": "De zijbalknavigatie bevat nuttige contextspecifieke links. Hier kun je de geschiedenis bekijken hoe dit schema in de loop van de tijd is veranderd.",
"common.skipped": "Skipped",
"common.slug": "Slug", "common.slug": "Slug",
"common.stars.max": "Mag niet meer dan 15 sterren hebben", "common.stars.max": "Mag niet meer dan 15 sterren hebben",
"common.status": "Status", "common.status": "Status",
@ -528,10 +538,6 @@
"dashboard.trafficSummaryCard": "API Verkeer Samenvatting", "dashboard.trafficSummaryCard": "API Verkeer Samenvatting",
"dashboard.welcomeText": "Welkom bij **{app}** dashboard.", "dashboard.welcomeText": "Welkom bij **{app}** dashboard.",
"dashboard.welcomeTitle": "Hallo {user}", "dashboard.welcomeTitle": "Hallo {user}",
"editors.deleteConfirmText": "Do you really want to remove this Editor URL?",
"editors.deleteConfirmTitle": "Delete Editor URL",
"editors.empty": "No Editor URL created yet.",
"editors.title": "Custom Editors",
"eventConsumers.count": "Tellen", "eventConsumers.count": "Tellen",
"eventConsumers.loadFailed": "Kan gebeurtenisgebruikers niet laden. Laad opnieuw.", "eventConsumers.loadFailed": "Kan gebeurtenisgebruikers niet laden. Laad opnieuw.",
"eventConsumers.pageTitle": "Evenementconsumenten", "eventConsumers.pageTitle": "Evenementconsumenten",
@ -563,10 +569,6 @@
"news.headline": "Wat is er nieuw?", "news.headline": "Wat is er nieuw?",
"news.title": "Nieuwe functies", "news.title": "Nieuwe functies",
"notifo.subscripeTooltip": "Klik op deze knop om je te abonneren op alle wijzigingen en om pushmeldingen te ontvangen.", "notifo.subscripeTooltip": "Klik op deze knop om je te abonneren op alle wijzigingen en om pushmeldingen te ontvangen.",
"patterns.deleteConfirmText": "Wil je dit patroon echt verwijderen?",
"patterns.deleteConfirmTitle": "Verwijder patroon",
"patterns.empty": "Nog geen patroon gemaakt.",
"patterns.title": "Patterns",
"plans.billingPortal": "Factureringsportal", "plans.billingPortal": "Factureringsportal",
"plans.billingPortalHint": "Ga naar het factureringsportaal voor betalingsgeschiedenis en abonnementsoverzicht.", "plans.billingPortalHint": "Ga naar het factureringsportaal voor betalingsgeschiedenis en abonnementsoverzicht.",
"plans.change": "Wijzigen", "plans.change": "Wijzigen",
@ -611,7 +613,8 @@
"roles.revokeFailed": "Kan rol niet intrekken. Laad opnieuw.", "roles.revokeFailed": "Kan rol niet intrekken. Laad opnieuw.",
"roles.roleNamePlaceholder": "Voer de rolnaam in", "roles.roleNamePlaceholder": "Voer de rolnaam in",
"roles.updateFailed": "Update rol mislukt. Laad opnieuw.", "roles.updateFailed": "Update rol mislukt. Laad opnieuw.",
"rules.actionEdit": "Bewerk actie", "rules.actionData": "Action Data",
"rules.actionHint": "The selection of the action type cannot be changed later.",
"rules.cancelFailed": "Annuleren van regel is mislukt. Laad opnieuw.", "rules.cancelFailed": "Annuleren van regel is mislukt. Laad opnieuw.",
"rules.create": "Maak een nieuwe regel", "rules.create": "Maak een nieuwe regel",
"rules.createFailed": "Maken van regel is mislukt. Laad opnieuw.", "rules.createFailed": "Maken van regel is mislukt. Laad opnieuw.",
@ -619,10 +622,8 @@
"rules.deleteConfirmText": "Wil je de regel echt verwijderen?", "rules.deleteConfirmText": "Wil je de regel echt verwijderen?",
"rules.deleteConfirmTitle": "Regel verwijderen", "rules.deleteConfirmTitle": "Regel verwijderen",
"rules.deleteFailed": "Verwijderen van regel is mislukt. Laad opnieuw.", "rules.deleteFailed": "Verwijderen van regel is mislukt. Laad opnieuw.",
"rules.disableFailed": "Kan regel niet uitschakelen. Laad opnieuw.",
"rules.empty": "Nog geen regel aangemaakt.", "rules.empty": "Nog geen regel aangemaakt.",
"rules.emptyAddRule": "Regel toevoegen", "rules.emptyAddRule": "Regel toevoegen",
"rules.enableFailed": "Kan regel niet inschakelen. Laad opnieuw.",
"rules.enqueued": "Regel is toegevoegd aan de wachtrij.", "rules.enqueued": "Regel is toegevoegd aan de wachtrij.",
"rules.itemPageTitle": "Rule", "rules.itemPageTitle": "Rule",
"rules.listPageTitle": "Regels", "rules.listPageTitle": "Regels",
@ -642,6 +643,7 @@
"rules.ruleEvents.nextAttemptLabel": "Volgende", "rules.ruleEvents.nextAttemptLabel": "Volgende",
"rules.ruleEvents.numAttemptsLabel": "Pogingen", "rules.ruleEvents.numAttemptsLabel": "Pogingen",
"rules.ruleEvents.reloaded": "RuleEvents herladen.", "rules.ruleEvents.reloaded": "RuleEvents herladen.",
"rules.ruleSimulator.listPageTitle": "Simulator",
"rules.ruleSyntax.if": "If", "rules.ruleSyntax.if": "If",
"rules.ruleSyntax.then": "then", "rules.ruleSyntax.then": "then",
"rules.run": "Uitvoeren", "rules.run": "Uitvoeren",
@ -650,17 +652,16 @@
"rules.runningRule": "Regel '{name}' is momenteel actief.", "rules.runningRule": "Regel '{name}' is momenteel actief.",
"rules.runRuleConfirmText": "Wil je de regel echt voor alle evenementen uitvoeren?", "rules.runRuleConfirmText": "Wil je de regel echt voor alle evenementen uitvoeren?",
"rules.runRuleConfirmTitle": "Regel uitvoeren", "rules.runRuleConfirmTitle": "Regel uitvoeren",
"rules.simulate": "Simulate",
"rules.simulateTooltip": "Simulate this rules using the last 100 events.",
"rules.simulator": "Simulator",
"rules.stop": "Regel stopt binnenkort.", "rules.stop": "Regel stopt binnenkort.",
"rules.triggerConfirmText": "Wil je echt de regel activeren?", "rules.triggerConfirmText": "Wil je echt de regel activeren?",
"rules.triggerConfirmTitle": "Trigger regel", "rules.triggerConfirmTitle": "Trigger regel",
"rules.triggerEdit": "Trigger bewerken",
"rules.triggerFailed": "Kan regel niet activeren. Laad opnieuw.", "rules.triggerFailed": "Kan regel niet activeren. Laad opnieuw.",
"rules.triggerHint": "The selection of the trigger type cannot be changed later.",
"rules.unnamed": "Naamloos regel", "rules.unnamed": "Naamloos regel",
"rules.updateFailed": "Update regel mislukt. Laad opnieuw.", "rules.updateFailed": "Update regel mislukt. Laad opnieuw.",
"rules.wizard.actionHint": "De selectie van het actietype kan later niet worden gewijzigd.",
"rules.wizard.selectAction": "Selecteer actie",
"rules.wizard.selectTrigger": "Selecteer Trigger",
"rules.wizard.triggerHint": "De selectie van het triggertype kan later niet worden gewijzigd.",
"schemas.addField": "Veld toevoegen", "schemas.addField": "Veld toevoegen",
"schemas.addFieldAndClose": "Maken en sluiten", "schemas.addFieldAndClose": "Maken en sluiten",
"schemas.addFieldAndCreate": "Maak en voeg veld toe", "schemas.addFieldAndCreate": "Maak en voeg veld toe",
@ -883,7 +884,7 @@
"tour.step1Next": "Doorgaan", "tour.step1Next": "Doorgaan",
"tour.step1Text": "Een app is de opslagplaats voor uw project, bijvoorbeeld (blog, webshop of mobiele app). Je kunt bijdragers aan uw app toewijzen om samen te werken. \n \n Je kunt een onbeperkt aantal apps maken in Squidex om meerdere projecten tegelijkertijd te beheren. ", "tour.step1Text": "Een app is de opslagplaats voor uw project, bijvoorbeeld (blog, webshop of mobiele app). Je kunt bijdragers aan uw app toewijzen om samen te werken. \n \n Je kunt een onbeperkt aantal apps maken in Squidex om meerdere projecten tegelijkertijd te beheren. ",
"tour.step2Next": "Ga door!", "tour.step2Next": "Ga door!",
"tour.step2Text": "Schema's bepalen de structuur van uw inhoud, de velden en de gegevenstypen van een inhoudsitem. \n \n Voordat je inhoud aan uw schema kunt toevoegen, moet je op de knop 'Publiceren' bovenaan klikken om het schema beschikbaar te maken voor uw inhoudeditors. ", "tour.step2Text": "Schema's bepalen de structuur van uw inhoud, de velden en de gegevenstypen van een inhoudsitem. \n \n Voordat je inhoud aan uw schema kunt toevoegen, moet je op de knop 'Publiceren' bovenaan klikken om het schema beschikbaar te maken voor uw inhoudappSettings.editors. ",
"tour.step3Next": "Bijna klaar!", "tour.step3Next": "Bijna klaar!",
"tour.step3Text": "Inhoud zijn de feitelijke gegevens in uw app die zijn gegroepeerd op basis van het schema. \n \n Selecteer eerst een gepubliceerd schema en voeg vervolgens inhoud toe voor dit schema.", "tour.step3Text": "Inhoud zijn de feitelijke gegevens in uw app die zijn gegroepeerd op basis van het schema. \n \n Selecteer eerst een gepubliceerd schema en voeg vervolgens inhoud toe voor dit schema.",
"tour.step4Next": "Begrepen!", "tour.step4Next": "Begrepen!",

6
backend/i18n/source/frontend__ignore.json

@ -193,13 +193,13 @@
"/shared/services/schemas.types.ts": [ "/shared/services/schemas.types.ts": [
"Invalid properties type" "Invalid properties type"
], ],
"/shared/state/appSettings.patterns.forms.ts": [
"[A-z0-9]+[A-z0-9\\- ]*[A-z0-9]"
],
"/shared/state/contents.forms.visitors.ts": [ "/shared/state/contents.forms.visitors.ts": [
"<Json />", "<Json />",
"yyyy-MM-dd HH:mm:ss" "yyyy-MM-dd HH:mm:ss"
], ],
"/shared/state/patterns.forms.ts": [
"[A-z0-9]+[A-z0-9\\- ]*[A-z0-9]"
],
"/shared/state/query.ts": [ "/shared/state/query.ts": [
"ends with", "ends with",
"is empty", "is empty",

39
backend/i18n/source/frontend_en.json

@ -41,9 +41,11 @@
"apps.leaveFailed": "Failed to leave app. Please reload.", "apps.leaveFailed": "Failed to leave app. Please reload.",
"apps.listPageTitle": "Apps", "apps.listPageTitle": "Apps",
"apps.loadFailed": "Failed to load apps. Please reload.", "apps.loadFailed": "Failed to load apps. Please reload.",
"apps.loadSettingsFailed": "Failed to update UI settings. Please reload.",
"apps.removeImage": "Remove image", "apps.removeImage": "Remove image",
"apps.removeImageFailed": "Failed to remove app image. Please reload.", "apps.removeImageFailed": "Failed to remove app image. Please reload.",
"apps.updateFailed": "Failed to update app. Please reload.", "apps.updateFailed": "Failed to update app. Please reload.",
"apps.updateSettingsFailed": "Faield to update UI settings. Please reload.",
"apps.upgradeHintCurrent": "You are on the {plan} plan.", "apps.upgradeHintCurrent": "You are on the {plan} plan.",
"apps.upgradeHintUpgrade": "Upgrade!", "apps.upgradeHintUpgrade": "Upgrade!",
"apps.uploadImage": "Drop an file to replace the app image. Use a square size.", "apps.uploadImage": "Drop an file to replace the app image. Use a square size.",
@ -52,11 +54,18 @@
"apps.uploadImageTooBig": "App image is too big.", "apps.uploadImageTooBig": "App image is too big.",
"apps.welcomeSubtitle": "Welcome to Squidex.", "apps.welcomeSubtitle": "Welcome to Squidex.",
"apps.welcomeTitle": "Hi {user}", "apps.welcomeTitle": "Hi {user}",
"appSettings.editors.deleteConfirmText": "Do you really want to remove this Editor URL?",
"appSettings.editors.deleteConfirmTitle": "Delete Editor URL",
"appSettings.editors.empty": "No Editor URL created yet.",
"appSettings.editors.title": "Custom Editors",
"appSettings.hideScheduler": "Hide dialog for scheduled publishing", "appSettings.hideScheduler": "Hide dialog for scheduled publishing",
"appSettings.patterns.deleteConfirmText": "Do you really want to remove this pattern?",
"appSettings.patterns.deleteConfirmTitle": "Delete pattern",
"appSettings.patterns.empty": "No pattern created yet.",
"appSettings.patterns.title": "Patterns",
"appSettings.refreshTooltip": "Refresh UI Settings (CTRL + SHIFT + R)", "appSettings.refreshTooltip": "Refresh UI Settings (CTRL + SHIFT + R)",
"appSettings.reloaded": "UI Settings reloaded.", "appSettings.reloaded": "UI Settings reloaded.",
"appSettings.title": "UI Settings", "appSettings.title": "UI Settings",
"appSettings.updateFailed": "Failed to update UI settings. Please reload.",
"assets.createFolder": "Create Folder", "assets.createFolder": "Create Folder",
"assets.createFolderFailed": "Failed to create asset folder. Please reload.", "assets.createFolderFailed": "Failed to create asset folder. Please reload.",
"assets.createFolderTooltip": "Create new folder (CTRL + SHIFT + G)", "assets.createFolderTooltip": "Create new folder (CTRL + SHIFT + G)",
@ -331,6 +340,7 @@
"common.settings": "Settings", "common.settings": "Settings",
"common.sidebar": "Sidebar Extension", "common.sidebar": "Sidebar Extension",
"common.sidebarTour": "The sidebar navigation contains useful context specific links. Here you can view the history how this schema has changed over time.", "common.sidebarTour": "The sidebar navigation contains useful context specific links. Here you can view the history how this schema has changed over time.",
"common.skipped": "Skipped",
"common.slug": "Slug", "common.slug": "Slug",
"common.stars.max": "Must not have more more than 15 stars", "common.stars.max": "Must not have more more than 15 stars",
"common.status": "Status", "common.status": "Status",
@ -528,10 +538,6 @@
"dashboard.trafficSummaryCard": "API Traffic Summary", "dashboard.trafficSummaryCard": "API Traffic Summary",
"dashboard.welcomeText": "Welcome to **{app}** dashboard.", "dashboard.welcomeText": "Welcome to **{app}** dashboard.",
"dashboard.welcomeTitle": "Hi {user}", "dashboard.welcomeTitle": "Hi {user}",
"editors.deleteConfirmText": "Do you really want to remove this Editor URL?",
"editors.deleteConfirmTitle": "Delete Editor URL",
"editors.empty": "No Editor URL created yet.",
"editors.title": "Custom Editors",
"eventConsumers.count": "Count", "eventConsumers.count": "Count",
"eventConsumers.loadFailed": "Failed to load event consumers. Please reload.", "eventConsumers.loadFailed": "Failed to load event consumers. Please reload.",
"eventConsumers.pageTitle": "Event Consumers", "eventConsumers.pageTitle": "Event Consumers",
@ -563,10 +569,6 @@
"news.headline": "What's new?", "news.headline": "What's new?",
"news.title": "New Features", "news.title": "New Features",
"notifo.subscripeTooltip": "Click this button to subscribe to all changes and to receive push notifications.", "notifo.subscripeTooltip": "Click this button to subscribe to all changes and to receive push notifications.",
"patterns.deleteConfirmText": "Do you really want to remove this pattern?",
"patterns.deleteConfirmTitle": "Delete pattern",
"patterns.empty": "No pattern created yet.",
"patterns.title": "Patterns",
"plans.billingPortal": "Billing Portal", "plans.billingPortal": "Billing Portal",
"plans.billingPortalHint": "Go to Billing Portal for payment history and subscription overview.", "plans.billingPortalHint": "Go to Billing Portal for payment history and subscription overview.",
"plans.change": "Change", "plans.change": "Change",
@ -590,7 +592,7 @@
"roles.addFailed": "Failed to add role. Please reload.", "roles.addFailed": "Failed to add role. Please reload.",
"roles.default.owner": "Can do everything, including deleting the app.", "roles.default.owner": "Can do everything, including deleting the app.",
"roles.default.reader": "Can only read assets and contents.", "roles.default.reader": "Can only read assets and contents.",
"roles.defaults.developer": "Can use the API view, edit assets, contents, schemas, rules, workflows and patterns.", "roles.defaults.developer": "Can use the API view, edit assets, contents, schemas, rules, workflows and appSettings.patterns.",
"roles.defaults.editor": "Can edit assets and contents and view workflows.", "roles.defaults.editor": "Can edit assets and contents and view workflows.",
"roles.deleteConfirmText": "Delete role", "roles.deleteConfirmText": "Delete role",
"roles.deleteConfirmTitle": "Do you really want to delete the role?", "roles.deleteConfirmTitle": "Do you really want to delete the role?",
@ -611,7 +613,8 @@
"roles.revokeFailed": "Failed to revoke role. Please reload.", "roles.revokeFailed": "Failed to revoke role. Please reload.",
"roles.roleNamePlaceholder": "Enter role name", "roles.roleNamePlaceholder": "Enter role name",
"roles.updateFailed": "Failed to update role. Please reload.", "roles.updateFailed": "Failed to update role. Please reload.",
"rules.actionEdit": "Edit Action", "rules.actionData": "Action Data",
"rules.actionHint": "The selection of the action type cannot be changed later.",
"rules.cancelFailed": "Failed to cancel rule. Please reload.", "rules.cancelFailed": "Failed to cancel rule. Please reload.",
"rules.create": "New Rule", "rules.create": "New Rule",
"rules.createFailed": "Failed to create rule. Please reload.", "rules.createFailed": "Failed to create rule. Please reload.",
@ -619,10 +622,8 @@
"rules.deleteConfirmText": "Do you really want to delete the rule?", "rules.deleteConfirmText": "Do you really want to delete the rule?",
"rules.deleteConfirmTitle": "Delete rule", "rules.deleteConfirmTitle": "Delete rule",
"rules.deleteFailed": "Failed to delete rule. Please reload.", "rules.deleteFailed": "Failed to delete rule. Please reload.",
"rules.disableFailed": "Failed to disable rule. Please reload.",
"rules.empty": "No rule created yet.", "rules.empty": "No rule created yet.",
"rules.emptyAddRule": "Add Rule", "rules.emptyAddRule": "Add Rule",
"rules.enableFailed": "Failed to enable rule. Please reload.",
"rules.enqueued": "Rule has been added to the queue.", "rules.enqueued": "Rule has been added to the queue.",
"rules.itemPageTitle": "Rule", "rules.itemPageTitle": "Rule",
"rules.listPageTitle": "Rules", "rules.listPageTitle": "Rules",
@ -642,6 +643,7 @@
"rules.ruleEvents.nextAttemptLabel": "Next", "rules.ruleEvents.nextAttemptLabel": "Next",
"rules.ruleEvents.numAttemptsLabel": "Attempts", "rules.ruleEvents.numAttemptsLabel": "Attempts",
"rules.ruleEvents.reloaded": "RuleEvents reloaded.", "rules.ruleEvents.reloaded": "RuleEvents reloaded.",
"rules.ruleSimulator.listPageTitle": "Simulator",
"rules.ruleSyntax.if": "If", "rules.ruleSyntax.if": "If",
"rules.ruleSyntax.then": "then", "rules.ruleSyntax.then": "then",
"rules.run": "Run", "rules.run": "Run",
@ -650,17 +652,16 @@
"rules.runningRule": "Rule '{name}' is currently running.", "rules.runningRule": "Rule '{name}' is currently running.",
"rules.runRuleConfirmText": "Do you really want to run the rule for all events?", "rules.runRuleConfirmText": "Do you really want to run the rule for all events?",
"rules.runRuleConfirmTitle": "Run rule", "rules.runRuleConfirmTitle": "Run rule",
"rules.simulate": "Simulate",
"rules.simulateTooltip": "Simulate this rules using the last 100 events.",
"rules.simulator": "Simulator",
"rules.stop": "Rule will stop soon.", "rules.stop": "Rule will stop soon.",
"rules.triggerConfirmText": "Do you really want to trigger the rule?", "rules.triggerConfirmText": "Do you really want to trigger the rule?",
"rules.triggerConfirmTitle": "Trigger rule", "rules.triggerConfirmTitle": "Trigger rule",
"rules.triggerEdit": "Edit Trigger",
"rules.triggerFailed": "Failed to trigger rule. Please reload.", "rules.triggerFailed": "Failed to trigger rule. Please reload.",
"rules.triggerHint": "The selection of the trigger type cannot be changed later.",
"rules.unnamed": "Unnamed Rule", "rules.unnamed": "Unnamed Rule",
"rules.updateFailed": "Failed to update rule. Please reload.", "rules.updateFailed": "Failed to update rule. Please reload.",
"rules.wizard.actionHint": "The selection of the action type cannot be changed later.",
"rules.wizard.selectAction": "Select Action",
"rules.wizard.selectTrigger": "Select Trigger",
"rules.wizard.triggerHint": "The selection of the trigger type cannot be changed later.",
"schemas.addField": "Add Field", "schemas.addField": "Add Field",
"schemas.addFieldAndClose": "Create and close", "schemas.addFieldAndClose": "Create and close",
"schemas.addFieldAndCreate": "Create and add field", "schemas.addFieldAndCreate": "Create and add field",
@ -883,7 +884,7 @@
"tour.step1Next": "Continue", "tour.step1Next": "Continue",
"tour.step1Text": "An App is the repository for your project, e.g. (blog, web shop or mobile app). You can assign contributors to your app to work together.\n\nYou can create an unlimited number of Apps in Squidex to manage multiple projects at the same time.", "tour.step1Text": "An App is the repository for your project, e.g. (blog, web shop or mobile app). You can assign contributors to your app to work together.\n\nYou can create an unlimited number of Apps in Squidex to manage multiple projects at the same time.",
"tour.step2Next": "Keep going!", "tour.step2Next": "Keep going!",
"tour.step2Text": "Schemas define the structure of your content, the fields and the data types of a content item.\n\nBefore you can add content to your schema, make sure to hit the 'Publish' button at the top to make the schema available to your content editors.", "tour.step2Text": "Schemas define the structure of your content, the fields and the data types of a content item.\n\nBefore you can add content to your schema, make sure to hit the 'Publish' button at the top to make the schema available to your content appSettings.editors.",
"tour.step3Next": "Almost there!", "tour.step3Next": "Almost there!",
"tour.step3Text": "Content is the actual data in your app which is grouped by the schema.\n\nSelect a published schema first, then add content for this schema.", "tour.step3Text": "Content is the actual data in your app which is grouped by the schema.\n\nSelect a published schema first, then add content for this schema.",
"tour.step4Next": "Got It!", "tour.step4Next": "Got It!",

6
backend/i18n/source/frontend_it.json

@ -52,6 +52,9 @@
"apps.uploadImageTooBig": "L'immagine dell'app è troppo grande.", "apps.uploadImageTooBig": "L'immagine dell'app è troppo grande.",
"apps.welcomeSubtitle": "Benvenuto su Squidex.", "apps.welcomeSubtitle": "Benvenuto su Squidex.",
"apps.welcomeTitle": "Ciao {user}", "apps.welcomeTitle": "Ciao {user}",
"appSettings.patterns.deleteConfirmText": "Sei sicuro di voler rimuovere il pattern?",
"appSettings.patterns.deleteConfirmTitle": "Cancella il pattern",
"appSettings.patterns.empty": "Nessun pattern è stato ancora creato.",
"assets.createFolder": "Crea cartella", "assets.createFolder": "Crea cartella",
"assets.createFolderFailed": "Non è stato possibile creare la cartella degli asset. Per favore ricarica.", "assets.createFolderFailed": "Non è stato possibile creare la cartella degli asset. Per favore ricarica.",
"assets.createFolderTooltip": "Crea una nuova cartella (CTRL + SHIFT + G)", "assets.createFolderTooltip": "Crea una nuova cartella (CTRL + SHIFT + G)",
@ -545,9 +548,6 @@
"news.headline": "Che cosa c'è di nuovo?", "news.headline": "Che cosa c'è di nuovo?",
"news.title": "Nuove funzionalità", "news.title": "Nuove funzionalità",
"notifo.subscripeTooltip": "Fai clic su questo pulsante per iscriverti a tutte le modifiche e ricevere le notifiche push.", "notifo.subscripeTooltip": "Fai clic su questo pulsante per iscriverti a tutte le modifiche e ricevere le notifiche push.",
"patterns.deleteConfirmText": "Sei sicuro di voler rimuovere il pattern?",
"patterns.deleteConfirmTitle": "Cancella il pattern",
"patterns.empty": "Nessun pattern è stato ancora creato.",
"plans.billingPortal": "Portale di fatturazione", "plans.billingPortal": "Portale di fatturazione",
"plans.billingPortalHint": "Vai al portale di fatturazione per lo storico dei pagamenti e una panoramica per l'abbonamento.", "plans.billingPortalHint": "Vai al portale di fatturazione per lo storico dei pagamenti e una panoramica per l'abbonamento.",
"plans.change": "Cambia", "plans.change": "Cambia",

8
backend/i18n/source/frontend_nl.json

@ -48,6 +48,9 @@
"apps.uploadImageTooBig": "App-afbeelding is te groot.", "apps.uploadImageTooBig": "App-afbeelding is te groot.",
"apps.welcomeSubtitle": "Welkom bij Squidex.", "apps.welcomeSubtitle": "Welkom bij Squidex.",
"apps.welcomeTitle": "Hallo {user}", "apps.welcomeTitle": "Hallo {user}",
"appSettings.patterns.deleteConfirmText": "Wil je dit patroon echt verwijderen?",
"appSettings.patterns.deleteConfirmTitle": "Verwijder patroon",
"appSettings.patterns.empty": "Nog geen patroon gemaakt.",
"assets.createFolder": "Map maken", "assets.createFolder": "Map maken",
"assets.createFolderFailed": "Maken van een map is mislukt. Laad opnieuw.", "assets.createFolderFailed": "Maken van een map is mislukt. Laad opnieuw.",
"assets.createFolderTooltip": "Nieuwe map maken (CTRL + SHIFT + G)", "assets.createFolderTooltip": "Nieuwe map maken (CTRL + SHIFT + G)",
@ -517,9 +520,6 @@
"news.headline": "Wat is er nieuw?", "news.headline": "Wat is er nieuw?",
"news.title": "Nieuwe functies", "news.title": "Nieuwe functies",
"notifo.subscripeTooltip": "Klik op deze knop om je te abonneren op alle wijzigingen en om pushmeldingen te ontvangen.", "notifo.subscripeTooltip": "Klik op deze knop om je te abonneren op alle wijzigingen en om pushmeldingen te ontvangen.",
"patterns.deleteConfirmText": "Wil je dit patroon echt verwijderen?",
"patterns.deleteConfirmTitle": "Verwijder patroon",
"patterns.empty": "Nog geen patroon gemaakt.",
"plans.billingPortal": "Factureringsportal", "plans.billingPortal": "Factureringsportal",
"plans.billingPortalHint": "Ga naar het factureringsportaal voor betalingsgeschiedenis en abonnementsoverzicht.", "plans.billingPortalHint": "Ga naar het factureringsportaal voor betalingsgeschiedenis en abonnementsoverzicht.",
"plans.change": "Wijzigen", "plans.change": "Wijzigen",
@ -820,7 +820,7 @@
"tour.step1Next": "Doorgaan", "tour.step1Next": "Doorgaan",
"tour.step1Text": "Een app is de opslagplaats voor uw project, bijvoorbeeld (blog, webshop of mobiele app). Je kunt bijdragers aan uw app toewijzen om samen te werken. \n \n Je kunt een onbeperkt aantal apps maken in Squidex om meerdere projecten tegelijkertijd te beheren. ", "tour.step1Text": "Een app is de opslagplaats voor uw project, bijvoorbeeld (blog, webshop of mobiele app). Je kunt bijdragers aan uw app toewijzen om samen te werken. \n \n Je kunt een onbeperkt aantal apps maken in Squidex om meerdere projecten tegelijkertijd te beheren. ",
"tour.step2Next": "Ga door!", "tour.step2Next": "Ga door!",
"tour.step2Text": "Schema's bepalen de structuur van uw inhoud, de velden en de gegevenstypen van een inhoudsitem. \n \n Voordat je inhoud aan uw schema kunt toevoegen, moet je op de knop 'Publiceren' bovenaan klikken om het schema beschikbaar te maken voor uw inhoudeditors. ", "tour.step2Text": "Schema's bepalen de structuur van uw inhoud, de velden en de gegevenstypen van een inhoudsitem. \n \n Voordat je inhoud aan uw schema kunt toevoegen, moet je op de knop 'Publiceren' bovenaan klikken om het schema beschikbaar te maken voor uw inhoudappSettings.editors. ",
"tour.step3Next": "Bijna klaar!", "tour.step3Next": "Bijna klaar!",
"tour.step3Text": "Inhoud zijn de feitelijke gegevens in uw app die zijn gegroepeerd op basis van het schema. \n \n Selecteer eerst een gepubliceerd schema en voeg vervolgens inhoud toe voor dit schema.", "tour.step3Text": "Inhoud zijn de feitelijke gegevens in uw app die zijn gegroepeerd op basis van het schema. \n \n Selecteer eerst een gepubliceerd schema en voeg vervolgens inhoud toe voor dit schema.",
"tour.step4Next": "Begrepen!", "tour.step4Next": "Begrepen!",

8
backend/i18n/translate_en.bat

@ -0,0 +1,8 @@
cd translator\Squidex.Translator
dotnet run translate check-backend ..\..\..\.. -l en
dotnet run translate check-frontend ..\..\..\.. -l en
dotnet run translate gen-frontend ..\..\..\.. -l en
dotnet run translate gen-backend ..\..\..\.. -l en

21
backend/i18n/translator/Squidex.Translator/Commands.cs

@ -6,13 +6,17 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using CommandDotNet; using CommandDotNet;
using FluentValidation; using FluentValidation;
using FluentValidation.Attributes; using FluentValidation.Attributes;
using Squidex.Translator.Processes; using Squidex.Translator.Processes;
using Squidex.Translator.State; using Squidex.Translator.State;
#pragma warning disable CA1822 // Mark members as static
namespace Squidex.Translator namespace Squidex.Translator
{ {
public class Commands public class Commands
@ -120,7 +124,19 @@ namespace Squidex.Translator
throw new ArgumentException("Folder does not exist."); throw new ArgumentException("Folder does not exist.");
} }
var locales = new string[] { "en", "nl", "it" }; var supportedLocaled = new string[] { "en", "nl", "it" };
var locales = supportedLocaled;
if (arguments.Locales != null && arguments.Locales.Any())
{
locales = supportedLocaled.Intersect(arguments.Locales).ToArray();
}
if (locales.Length == 0)
{
locales = supportedLocaled;
}
var translationsDirectory = new DirectoryInfo(Path.Combine(arguments.Folder, "backend", "i18n")); var translationsDirectory = new DirectoryInfo(Path.Combine(arguments.Folder, "backend", "i18n"));
var translationsService = new TranslationService(translationsDirectory, fileName, locales, arguments.SingleWords); var translationsService = new TranslationService(translationsDirectory, fileName, locales, arguments.SingleWords);
@ -141,6 +157,9 @@ namespace Squidex.Translator
[Option(LongName = "report", ShortName = "r")] [Option(LongName = "report", ShortName = "r")]
public bool Report { get; set; } public bool Report { get; set; }
[Option(LongName = "locale", ShortName = "l")]
public IEnumerable<string> Locales { get; set; }
public sealed class Validator : AbstractValidator<TranslateArguments> public sealed class Validator : AbstractValidator<TranslateArguments>
{ {
public Validator() public Validator()

2
backend/i18n/translator/Squidex.Translator/Processes/Helper.cs

@ -17,7 +17,7 @@ namespace Squidex.Translator.Processes
{ {
public static string RelativeName(FileInfo file, DirectoryInfo folder) public static string RelativeName(FileInfo file, DirectoryInfo folder)
{ {
return file.FullName.Substring(folder.FullName.Length).Replace("\\", "/"); return file.FullName[folder.FullName.Length..].Replace("\\", "/");
} }
public static void CheckOtherLocales(TranslationService service) public static void CheckOtherLocales(TranslationService service)

6
backend/src/Migrations/Migrations/CreateAppSettings.cs

@ -41,7 +41,7 @@ namespace Migrations.Migrations
{ {
var apps = new Dictionary<NamedId<DomainId>, Dictionary<DomainId, (string Name, string Pattern, string? Message)>>(); var apps = new Dictionary<NamedId<DomainId>, Dictionary<DomainId, (string Name, string Pattern, string? Message)>>();
await eventStore.QueryAsync(storedEvent => await foreach (var storedEvent in eventStore.QueryAllAsync("^app\\-"))
{ {
var @event = eventDataFormatter.ParseIfKnown(storedEvent); var @event = eventDataFormatter.ParseIfKnown(storedEvent);
@ -80,9 +80,7 @@ namespace Migrations.Migrations
} }
} }
} }
}
return Task.CompletedTask;
}, "^app\\-");
var actor = RefToken.Client("Migrator"); var actor = RefToken.Client("Migrator");

18
backend/src/Migrations/Migrations/PopulateGrainIndexes.cs

@ -75,7 +75,7 @@ namespace Migrations.Migrations
} }
} }
await eventStore.QueryAsync(storedEvent => await foreach (var storedEvent in eventStore.QueryAllAsync("^app\\-"))
{ {
var @event = eventDataFormatter.ParseIfKnown(storedEvent); var @event = eventDataFormatter.ParseIfKnown(storedEvent);
@ -109,9 +109,7 @@ namespace Migrations.Migrations
break; break;
} }
} }
}
return Task.CompletedTask;
}, "^app\\-");
await indexApps.RebuildAsync(appsByName); await indexApps.RebuildAsync(appsByName);
@ -130,7 +128,7 @@ namespace Migrations.Migrations
return rulesByApp!.GetOrAddNew(@event.AppId.Id); return rulesByApp!.GetOrAddNew(@event.AppId.Id);
} }
await eventStore.QueryAsync(storedEvent => await foreach (var storedEvent in eventStore.QueryAllAsync("^rule\\-"))
{ {
var @event = eventDataFormatter.ParseIfKnown(storedEvent); var @event = eventDataFormatter.ParseIfKnown(storedEvent);
@ -146,9 +144,7 @@ namespace Migrations.Migrations
break; break;
} }
} }
}
return Task.CompletedTask;
}, "^rule\\-");
foreach (var (appId, rules) in rulesByApp) foreach (var (appId, rules) in rulesByApp)
{ {
@ -165,7 +161,7 @@ namespace Migrations.Migrations
return schemasByApp!.GetOrAddNew(@event.AppId.Id); return schemasByApp!.GetOrAddNew(@event.AppId.Id);
} }
await eventStore.QueryAsync(storedEvent => await foreach (var storedEvent in eventStore.QueryAllAsync("^schema\\-"))
{ {
var @event = eventDataFormatter.ParseIfKnown(storedEvent); var @event = eventDataFormatter.ParseIfKnown(storedEvent);
@ -181,9 +177,7 @@ namespace Migrations.Migrations
break; break;
} }
} }
}
return Task.CompletedTask;
}, "^schema\\-");
foreach (var (appId, schemas) in schemasByApp) foreach (var (appId, schemas) in schemasByApp)
{ {

2
backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs

@ -13,7 +13,7 @@ namespace Squidex.Domain.Apps.Core.Rules.Triggers
[TypeName(nameof(ContentChangedTriggerV2))] [TypeName(nameof(ContentChangedTriggerV2))]
public sealed class ContentChangedTriggerV2 : RuleTrigger public sealed class ContentChangedTriggerV2 : RuleTrigger
{ {
public ReadOnlyCollection<ContentChangedTriggerSchemaV2> Schemas { get; set; } public ReadOnlyCollection<ContentChangedTriggerSchemaV2>? Schemas { get; set; }
public bool HandleAll { get; set; } public bool HandleAll { get; set; }

12
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleService.cs

@ -7,20 +7,22 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Core.HandleRules namespace Squidex.Domain.Apps.Core.HandleRules
{ {
public interface IRuleService public interface IRuleService
{ {
bool CanCreateSnapshotEvents(Rule rule); bool CanCreateSnapshotEvents(RuleContext context);
IAsyncEnumerable<(RuleJob? Job, Exception? Exception)> CreateSnapshotJobsAsync(Rule rule, DomainId ruleId, DomainId appId); string GetName(AppEvent @event);
Task<List<(RuleJob Job, Exception? Exception)>> CreateJobsAsync(Rule rule, DomainId ruleId, Envelope<IEvent> @event, bool ignoreStale = true); IAsyncEnumerable<JobResult> CreateSnapshotJobsAsync(RuleContext context, CancellationToken ct = default);
IAsyncEnumerable<JobResult> CreateJobsAsync(Envelope<IEvent> @event, RuleContext context, CancellationToken ct = default);
Task<(Result Result, TimeSpan Elapsed)> InvokeAsync(string actionName, string job); Task<(Result Result, TimeSpan Elapsed)> InvokeAsync(string actionName, string job);
} }

34
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs

@ -7,11 +7,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Linq;
using Squidex.Domain.Apps.Core.Rules; using System.Threading;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Core.HandleRules namespace Squidex.Domain.Apps.Core.HandleRules
@ -20,14 +19,33 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{ {
Type TriggerType { get; } Type TriggerType { get; }
bool CanCreateSnapshotEvents { get; } bool CanCreateSnapshotEvents
{
get => false;
}
IAsyncEnumerable<EnrichedEvent> CreateSnapshotEvents(RuleTrigger trigger, DomainId appId); IAsyncEnumerable<EnrichedEvent> CreateSnapshotEventsAsync(RuleContext context, CancellationToken ct)
{
return AsyncEnumerable.Empty<EnrichedEvent>();
}
Task<List<EnrichedEvent>> CreateEnrichedEventsAsync(Envelope<AppEvent> @event); IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context, CancellationToken ct);
bool Trigger(EnrichedEvent @event, RuleTrigger trigger); string? GetName(AppEvent @event)
{
return null;
}
bool Trigger(AppEvent @event, RuleTrigger trigger, DomainId ruleId); bool Trigger(Envelope<AppEvent> @event, RuleContext context)
{
return true;
}
bool Trigger(EnrichedEvent @event, RuleContext context)
{
return true;
}
bool Handles(AppEvent @event);
} }
} }

26
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/JobResult.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Domain.Apps.Core.Rules;
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Domain.Apps.Core.HandleRules
{
public sealed record JobResult(RuleJob? Job, Exception? Exception = null, SkipReason SkipReason = default)
{
public static readonly JobResult ConditionDoesNotMatch = new JobResult(null, null, SkipReason.ConditionDoesNotMatch);
public static readonly JobResult Disabled = new JobResult(null, null, SkipReason.Disabled);
public static readonly JobResult EventMismatch = new JobResult(null, null, SkipReason.EventMismatch);
public static readonly JobResult FromRule = new JobResult(null, null, SkipReason.FromRule);
public static readonly JobResult NoAction = new JobResult(null, null, SkipReason.NoAction);
public static readonly JobResult NoTrigger = new JobResult(null, null, SkipReason.NoTrigger);
public static readonly JobResult TooOld = new JobResult(null, null, SkipReason.TooOld);
public static readonly JobResult WrongEventForTrigger = new JobResult(null, null, SkipReason.WrongEventForTrigger);
}
}

23
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleContext.cs

@ -0,0 +1,23 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.HandleRules
{
public struct RuleContext
{
public NamedId<DomainId> AppId { get; init; }
public DomainId RuleId { get; init; }
public Rule Rule { get; init; }
public bool IgnoreStale { get; init; }
}
}

144
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs

@ -8,6 +8,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -68,11 +69,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules
this.log = log; this.log = log;
} }
public bool CanCreateSnapshotEvents(Rule rule) public bool CanCreateSnapshotEvents(RuleContext context)
{ {
Guard.NotNull(rule, nameof(rule)); Guard.NotNull(context.Rule, nameof(context.Rule));
if (!ruleTriggerHandlers.TryGetValue(rule.Trigger.GetType(), out var triggerHandler)) if (!ruleTriggerHandlers.TryGetValue(context.Rule.Trigger.GetType(), out var triggerHandler))
{ {
return false; return false;
} }
@ -80,8 +81,13 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return triggerHandler.CanCreateSnapshotEvents; return triggerHandler.CanCreateSnapshotEvents;
} }
public async IAsyncEnumerable<(RuleJob? Job, Exception? Exception)> CreateSnapshotJobsAsync(Rule rule, DomainId ruleId, DomainId appId) public async IAsyncEnumerable<JobResult> CreateSnapshotJobsAsync(RuleContext context,
[EnumeratorCancellation] CancellationToken ct = default)
{ {
Guard.NotNull(context.Rule, nameof(context.Rule));
var rule = context.Rule;
if (!rule.IsEnabled) if (!rule.IsEnabled)
{ {
yield break; yield break;
@ -104,68 +110,93 @@ namespace Squidex.Domain.Apps.Core.HandleRules
var now = clock.GetCurrentInstant(); var now = clock.GetCurrentInstant();
await foreach (var enrichedEvent in triggerHandler.CreateSnapshotEvents(rule.Trigger, appId)) await foreach (var enrichedEvent in triggerHandler.CreateSnapshotEventsAsync(context, ct))
{ {
Exception? exception; JobResult? job;
RuleJob? job = null;
try try
{ {
await eventEnricher.EnrichAsync(enrichedEvent, null); await eventEnricher.EnrichAsync(enrichedEvent, null);
if (!triggerHandler.Trigger(enrichedEvent, rule.Trigger)) if (!triggerHandler.Trigger(enrichedEvent, context))
{ {
continue; continue;
} }
(job, exception) = await CreateJobAsync(rule, ruleId, actionHandler, now, enrichedEvent); job = await CreateJobAsync(actionHandler, enrichedEvent, context, now);
} }
catch (Exception ex) catch (Exception ex)
{ {
exception = ex; job = new JobResult(null, ex);
} }
yield return (job, exception); yield return job;
} }
} }
public async Task<List<(RuleJob Job, Exception? Exception)>> CreateJobsAsync(Rule rule, DomainId ruleId, Envelope<IEvent> @event, bool ignoreStale = true) public async IAsyncEnumerable<JobResult> CreateJobsAsync(Envelope<IEvent> @event, RuleContext context,
[EnumeratorCancellation] CancellationToken ct = default)
{ {
Guard.NotNull(rule, nameof(rule));
Guard.NotNull(@event, nameof(@event)); Guard.NotNull(@event, nameof(@event));
var result = new List<(RuleJob Job, Exception? Exception)>(); var jobs = new List<JobResult>();
await AddJobsAsync(jobs, @event, context, ct);
foreach (var job in jobs)
{
if (ct.IsCancellationRequested)
{
break;
}
yield return job;
}
}
private async Task AddJobsAsync(List<JobResult> jobs, Envelope<IEvent> @event, RuleContext context, CancellationToken ct)
{
try try
{ {
var rule = context.Rule;
if (!rule.IsEnabled) if (!rule.IsEnabled)
{ {
return result; jobs.Add(JobResult.Disabled);
return;
} }
if (@event.Payload is not AppEvent) if (@event.Payload is not AppEvent)
{ {
return result; jobs.Add(JobResult.EventMismatch);
return;
} }
var typed = @event.To<AppEvent>(); var typed = @event.To<AppEvent>();
if (typed.Payload.FromRule) if (typed.Payload.FromRule)
{ {
return result; jobs.Add(JobResult.FromRule);
return;
} }
var actionType = rule.Action.GetType(); var actionType = rule.Action.GetType();
if (!ruleTriggerHandlers.TryGetValue(rule.Trigger.GetType(), out var triggerHandler)) if (!ruleTriggerHandlers.TryGetValue(rule.Trigger.GetType(), out var triggerHandler))
{ {
return result; jobs.Add(JobResult.NoTrigger);
return;
}
if (!triggerHandler.Handles(typed.Payload))
{
jobs.Add(JobResult.WrongEventForTrigger);
return;
} }
if (!ruleActionHandlers.TryGetValue(actionType, out var actionHandler)) if (!ruleActionHandlers.TryGetValue(actionType, out var actionHandler))
{ {
return result; jobs.Add(JobResult.NoAction);
return;
} }
var now = clock.GetCurrentInstant(); var now = clock.GetCurrentInstant();
@ -175,37 +206,50 @@ namespace Squidex.Domain.Apps.Core.HandleRules
@event.Headers.Timestamp() : @event.Headers.Timestamp() :
now; now;
if (ignoreStale && eventTime.Plus(Constants.StaleTime) < now) if (context.IgnoreStale && eventTime.Plus(Constants.StaleTime) < now)
{ {
return result; jobs.Add(JobResult.TooOld);
return;
} }
if (!triggerHandler.Trigger(typed.Payload, rule.Trigger, ruleId)) if (!triggerHandler.Trigger(typed, context))
{ {
return result; jobs.Add(JobResult.ConditionDoesNotMatch);
return;
} }
var appEventEnvelope = @event.To<AppEvent>(); await foreach (var enrichedEvent in triggerHandler.CreateEnrichedEventsAsync(typed, context, ct))
var enrichedEvents = await triggerHandler.CreateEnrichedEventsAsync(appEventEnvelope);
foreach (var enrichedEvent in enrichedEvents)
{ {
if (string.IsNullOrWhiteSpace(enrichedEvent.Name))
{
enrichedEvent.Name = GetName(typed.Payload);
}
try try
{ {
await eventEnricher.EnrichAsync(enrichedEvent, typed); await eventEnricher.EnrichAsync(enrichedEvent, typed);
if (!triggerHandler.Trigger(enrichedEvent, rule.Trigger)) if (!triggerHandler.Trigger(enrichedEvent, context))
{ {
if (jobs.Count == 0)
{
jobs.Add(JobResult.ConditionDoesNotMatch);
}
continue; continue;
} }
var (job, exception) = await CreateJobAsync(rule, ruleId, actionHandler, now, enrichedEvent); var job = await CreateJobAsync(actionHandler, enrichedEvent, context, now);
result.Add((job, exception)); jobs.Add(job);
} }
catch (Exception ex) catch (Exception ex)
{ {
if (jobs.Count == 0)
{
jobs.Add(new JobResult(null, ex, SkipReason.Failed));
}
log.LogError(ex, w => w log.LogError(ex, w => w
.WriteProperty("action", "createRuleJobFromEvent") .WriteProperty("action", "createRuleJobFromEvent")
.WriteProperty("status", "Failed")); .WriteProperty("status", "Failed"));
@ -214,17 +258,17 @@ namespace Squidex.Domain.Apps.Core.HandleRules
} }
catch (Exception ex) catch (Exception ex)
{ {
jobs.Add(new JobResult(null, ex, SkipReason.Failed));
log.LogError(ex, w => w log.LogError(ex, w => w
.WriteProperty("action", "createRuleJob") .WriteProperty("action", "createRuleJob")
.WriteProperty("status", "Failed")); .WriteProperty("status", "Failed"));
} }
return result;
} }
private async Task<(RuleJob, Exception?)> CreateJobAsync(Rule rule, DomainId ruleId, IRuleActionHandler actionHandler, Instant now, EnrichedEvent enrichedEvent) private async Task<JobResult> CreateJobAsync(IRuleActionHandler actionHandler, EnrichedEvent enrichedEvent, RuleContext context, Instant now)
{ {
var actionName = typeNameRegistry.GetName(rule.Action.GetType()); var actionName = typeNameRegistry.GetName(context.Rule.Action.GetType());
var expires = now.Plus(Constants.ExpirationTime); var expires = now.Plus(Constants.ExpirationTime);
@ -238,12 +282,12 @@ namespace Squidex.Domain.Apps.Core.HandleRules
EventName = enrichedEvent.Name, EventName = enrichedEvent.Name,
ExecutionPartition = enrichedEvent.Partition, ExecutionPartition = enrichedEvent.Partition,
Expires = expires, Expires = expires,
RuleId = ruleId RuleId = context.RuleId
}; };
try try
{ {
var (description, data) = await actionHandler.CreateJobAsync(enrichedEvent, rule.Action); var (description, data) = await actionHandler.CreateJobAsync(enrichedEvent, context.Rule.Action);
var json = jsonSerializer.Serialize(data); var json = jsonSerializer.Serialize(data);
@ -251,14 +295,32 @@ namespace Squidex.Domain.Apps.Core.HandleRules
job.ActionName = actionName; job.ActionName = actionName;
job.Description = description; job.Description = description;
return (job, null); return new JobResult(job, null);
} }
catch (Exception ex) catch (Exception ex)
{ {
job.Description = "Failed to create job"; job.Description = "Failed to create job";
return (job, ex); return new JobResult(job, ex);
}
}
public string GetName(AppEvent @event)
{
foreach (var handler in ruleTriggerHandlers.Values)
{
if (handler.Handles(@event))
{
var name = handler.GetName(@event);
if (!string.IsNullOrWhiteSpace(name))
{
return name;
}
}
} }
return @event.GetType().Name;
} }
public async Task<(Result Result, TimeSpan Elapsed)> InvokeAsync(string actionName, string job) public async Task<(Result Result, TimeSpan Elapsed)> InvokeAsync(string actionName, string job)

96
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleTriggerHandler.cs

@ -1,96 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Core.HandleRules
{
public abstract class RuleTriggerHandler<TTrigger, TEvent, TEnrichedEvent> : IRuleTriggerHandler
where TTrigger : RuleTrigger
where TEvent : AppEvent
where TEnrichedEvent : EnrichedEvent
{
private readonly List<EnrichedEvent> emptyEnrichedEvents = new List<EnrichedEvent>();
public Type TriggerType
{
get => typeof(TTrigger);
}
public virtual bool CanCreateSnapshotEvents
{
get => false;
}
public virtual async IAsyncEnumerable<EnrichedEvent> CreateSnapshotEvents(TTrigger trigger, DomainId appId)
{
await Task.Yield();
yield break;
}
public virtual async Task<List<EnrichedEvent>> CreateEnrichedEventsAsync(Envelope<AppEvent> @event)
{
var enrichedEvent = await CreateEnrichedEventAsync(@event.To<TEvent>());
if (enrichedEvent != null)
{
return new List<EnrichedEvent>
{
enrichedEvent
};
}
else
{
return emptyEnrichedEvents;
}
}
IAsyncEnumerable<EnrichedEvent> IRuleTriggerHandler.CreateSnapshotEvents(RuleTrigger trigger, DomainId appId)
{
return CreateSnapshotEvents((TTrigger)trigger, appId);
}
bool IRuleTriggerHandler.Trigger(EnrichedEvent @event, RuleTrigger trigger)
{
if (@event is TEnrichedEvent typed)
{
return Trigger(typed, (TTrigger)trigger);
}
return false;
}
bool IRuleTriggerHandler.Trigger(AppEvent @event, RuleTrigger trigger, DomainId ruleId)
{
if (@event is TEvent typed)
{
return Trigger(typed, (TTrigger)trigger, ruleId);
}
return false;
}
protected virtual Task<TEnrichedEvent?> CreateEnrichedEventAsync(Envelope<TEvent> @event)
{
return Task.FromResult<TEnrichedEvent?>(null);
}
protected abstract bool Trigger(TEnrichedEvent @event, TTrigger trigger);
protected virtual bool Trigger(TEvent @event, TTrigger trigger, DomainId ruleId)
{
return true;
}
}
}

23
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/SkipReason.cs

@ -0,0 +1,23 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.HandleRules
{
public enum SkipReason
{
None,
ConditionDoesNotMatch,
Disabled,
EventMismatch,
Failed,
FromRule,
NoAction,
NoTrigger,
TooOld,
WrongEventForTrigger
}
}

1
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -27,6 +27,7 @@
<PackageReference Include="Squidex.Jint" Version="3.0.0-beta-0" /> <PackageReference Include="Squidex.Jint" Version="3.0.0-beta-0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="5.0.0" /> <PackageReference Include="System.Collections.Immutable" Version="5.0.0" />
<PackageReference Include="System.Linq.Async" Version="5.0.0" />
<PackageReference Include="ValueTaskSupplement" Version="1.1.0" /> <PackageReference Include="ValueTaskSupplement" Version="1.1.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

8
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs

@ -7,6 +7,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Driver; using MongoDB.Driver;
@ -68,13 +69,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
}, ct); }, ct);
} }
public async IAsyncEnumerable<IAssetEntity> StreamAll(DomainId appId) public async IAsyncEnumerable<IAssetEntity> StreamAll(DomainId appId,
[EnumeratorCancellation] CancellationToken ct = default)
{ {
var find = Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted); var find = Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted);
using (var cursor = await find.ToCursorAsync()) using (var cursor = await find.ToCursorAsync(ct))
{ {
while (await cursor.MoveNextAsync()) while (await cursor.MoveNextAsync(ct))
{ {
foreach (var entity in cursor.Current) foreach (var entity in cursor.Current)
{ {

4
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs

@ -82,9 +82,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
await queryScheduled.PrepareAsync(collection, skipIndex, ct); await queryScheduled.PrepareAsync(collection, skipIndex, ct);
} }
public IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds) public IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds, CancellationToken ct)
{ {
return queryAsStream.StreamAll(appId, schemaIds); return queryAsStream.StreamAll(appId, schemaIds, ct);
} }
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> schemas, Q q) public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> schemas, Q q)

4
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs

@ -55,9 +55,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
await collectionPublished.InitializeAsync(ct); await collectionPublished.InitializeAsync(ct);
} }
public IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds) public IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds, CancellationToken ct)
{ {
return collectionAll.StreamAll(appId, schemaIds); return collectionAll.StreamAll(appId, schemaIds, ct);
} }
public Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> schemas, Q q, SearchScope scope) public Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> schemas, Q q, SearchScope scope)

8
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System.Collections.Generic; using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Driver; using MongoDB.Driver;
@ -27,16 +28,17 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
await Collection.Indexes.CreateOneAsync(indexBySchema, cancellationToken: ct); await Collection.Indexes.CreateOneAsync(indexBySchema, cancellationToken: ct);
} }
public async IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds) public async IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds,
[EnumeratorCancellation] CancellationToken ct)
{ {
var find = var find =
schemaIds != null ? schemaIds != null ?
Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && schemaIds.Contains(x.IndexedSchemaId)) : Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && schemaIds.Contains(x.IndexedSchemaId)) :
Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted); Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted);
using (var cursor = await find.ToCursorAsync()) using (var cursor = await find.ToCursorAsync(ct))
{ {
while (await cursor.MoveNextAsync()) while (await cursor.MoveNextAsync(ct))
{ {
foreach (var entity in cursor.Current) foreach (var entity in cursor.Current)
{ {

7
backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -169,6 +169,13 @@ namespace Squidex.Domain.Apps.Entities
return rules.ToList(); return rules.ToList();
} }
public async Task<IRuleEntity?> GetRuleAsync(DomainId appId, DomainId id)
{
var rules = await GetRulesAsync(appId);
return rules.Find(x => x.Id == id);
}
private static string AppCacheKey(DomainId appId) private static string AppCacheKey(DomainId appId)
{ {
return $"APPS_ID_{appId}"; return $"APPS_ID_{appId}";

43
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs

@ -5,13 +5,16 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Runtime.CompilerServices;
using System.Threading;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Assets; using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
@ -19,13 +22,15 @@ using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Assets namespace Squidex.Domain.Apps.Entities.Assets
{ {
public sealed class AssetChangedTriggerHandler : RuleTriggerHandler<AssetChangedTriggerV2, AssetEvent, EnrichedAssetEvent> public sealed class AssetChangedTriggerHandler : IRuleTriggerHandler
{ {
private readonly IScriptEngine scriptEngine; private readonly IScriptEngine scriptEngine;
private readonly IAssetLoader assetLoader; private readonly IAssetLoader assetLoader;
private readonly IAssetRepository assetRepository; private readonly IAssetRepository assetRepository;
public override bool CanCreateSnapshotEvents => true; public bool CanCreateSnapshotEvents => true;
public Type TriggerType => typeof(AssetChangedTriggerV2);
public AssetChangedTriggerHandler( public AssetChangedTriggerHandler(
IScriptEngine scriptEngine, IScriptEngine scriptEngine,
@ -41,9 +46,15 @@ namespace Squidex.Domain.Apps.Entities.Assets
this.assetRepository = assetRepository; this.assetRepository = assetRepository;
} }
public override async IAsyncEnumerable<EnrichedEvent> CreateSnapshotEvents(AssetChangedTriggerV2 trigger, DomainId appId) public bool Handles(AppEvent @event)
{
return @event is AssetEvent && @event is not AssetMoved;
}
public async IAsyncEnumerable<EnrichedEvent> CreateSnapshotEventsAsync(RuleContext context,
[EnumeratorCancellation] CancellationToken ct = default)
{ {
await foreach (var asset in assetRepository.StreamAll(appId)) await foreach (var asset in assetRepository.StreamAll(context.AppId.Id, ct))
{ {
var result = new EnrichedAssetEvent var result = new EnrichedAssetEvent
{ {
@ -53,24 +64,22 @@ namespace Squidex.Domain.Apps.Entities.Assets
SimpleMapper.Map(asset, result); SimpleMapper.Map(asset, result);
result.Actor = asset.LastModifiedBy; result.Actor = asset.LastModifiedBy;
result.Name = "AssetCreatedFromSnapshot"; result.Name = "AssetQueried";
yield return result; yield return result;
} }
} }
protected override async Task<EnrichedAssetEvent?> CreateEnrichedEventAsync(Envelope<AssetEvent> @event) public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
[EnumeratorCancellation] CancellationToken ct = default)
{ {
if (@event.Payload is AssetMoved) var assetEvent = (AssetEvent)@event.Payload;
{
return null;
}
var result = new EnrichedAssetEvent(); var result = new EnrichedAssetEvent();
var asset = await assetLoader.GetAsync( var asset = await assetLoader.GetAsync(
@event.Payload.AppId.Id, assetEvent.AppId.Id,
@event.Payload.AssetId, assetEvent.AssetId,
@event.Headers.EventStreamNumber()); @event.Headers.EventStreamNumber());
if (asset != null) if (asset != null)
@ -96,13 +105,13 @@ namespace Squidex.Domain.Apps.Entities.Assets
break; break;
} }
result.Name = $"Asset{result.Type}"; yield return result;
return result;
} }
protected override bool Trigger(EnrichedAssetEvent @event, AssetChangedTriggerV2 trigger) public bool Trigger(EnrichedEvent @event, RuleContext context)
{ {
var trigger = (AssetChangedTriggerV2)context.Rule.Trigger;
if (string.IsNullOrWhiteSpace(trigger.Condition)) if (string.IsNullOrWhiteSpace(trigger.Condition))
{ {
return true; return true;

4
backend/src/Squidex.Domain.Apps.Entities/Assets/RepairFiles.cs

@ -39,7 +39,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
public async Task RepairAsync(CancellationToken ct = default) public async Task RepairAsync(CancellationToken ct = default)
{ {
await eventStore.QueryAsync(async storedEvent => await foreach (var storedEvent in eventStore.QueryAllAsync("^asset\\-", ct: ct))
{ {
var @event = eventDataFormatter.ParseIfKnown(storedEvent); var @event = eventDataFormatter.ParseIfKnown(storedEvent);
@ -55,7 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
break; break;
} }
} }
}, "^asset\\-", ct: ct); }
} }
private async Task TryRepairAsync(NamedId<DomainId> appId, DomainId id, long fileVersion, CancellationToken ct) private async Task TryRepairAsync(NamedId<DomainId> appId, DomainId id, long fileVersion, CancellationToken ct)

3
backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -13,7 +14,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories
{ {
public interface IAssetRepository public interface IAssetRepository
{ {
IAsyncEnumerable<IAssetEntity> StreamAll(DomainId appId); IAsyncEnumerable<IAssetEntity> StreamAll(DomainId appId, CancellationToken ct);
Task<IResultList<IAssetEntity>> QueryAsync(DomainId appId, DomainId? parentId, Q q); Task<IResultList<IAssetEntity>> QueryAsync(DomainId appId, DomainId? parentId, Q q);

4
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs

@ -144,7 +144,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
var context = new BackupContext(appId, userMapping, writer); var context = new BackupContext(appId, userMapping, writer);
await eventStore.QueryAsync(async storedEvent => await foreach (var storedEvent in eventStore.QueryAllAsync(GetFilter(), ct: ct))
{ {
var @event = eventDataFormatter.Parse(storedEvent); var @event = eventDataFormatter.Parse(storedEvent);
@ -164,7 +164,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
job.HandledAssets = writer.WrittenAttachments; job.HandledAssets = writer.WrittenAttachments;
lastTimestamp = await WritePeriodically(lastTimestamp); lastTimestamp = await WritePeriodically(lastTimestamp);
}, GetFilter(), null, ct); }
foreach (var handler in handlers) foreach (var handler in handlers)
{ {

33
backend/src/Squidex.Domain.Apps.Entities/Comments/CommentTriggerHandler.cs

@ -5,8 +5,10 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Runtime.CompilerServices;
using System.Threading;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
@ -20,12 +22,13 @@ using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Comments namespace Squidex.Domain.Apps.Entities.Comments
{ {
public sealed class CommentTriggerHandler : RuleTriggerHandler<CommentTrigger, CommentCreated, EnrichedCommentEvent> public sealed class CommentTriggerHandler : IRuleTriggerHandler
{ {
private static readonly List<EnrichedEvent> EmptyResult = new List<EnrichedEvent>();
private readonly IScriptEngine scriptEngine; private readonly IScriptEngine scriptEngine;
private readonly IUserResolver userResolver; private readonly IUserResolver userResolver;
public Type TriggerType => typeof(CommentTrigger);
public CommentTriggerHandler(IScriptEngine scriptEngine, IUserResolver userResolver) public CommentTriggerHandler(IScriptEngine scriptEngine, IUserResolver userResolver)
{ {
Guard.NotNull(scriptEngine, nameof(scriptEngine)); Guard.NotNull(scriptEngine, nameof(scriptEngine));
@ -36,18 +39,22 @@ namespace Squidex.Domain.Apps.Entities.Comments
this.userResolver = userResolver; this.userResolver = userResolver;
} }
public override async Task<List<EnrichedEvent>> CreateEnrichedEventsAsync(Envelope<AppEvent> @event) public bool Handles(AppEvent @event)
{
return @event is CommentCreated;
}
public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
[EnumeratorCancellation] CancellationToken ct)
{ {
var commentCreated = @event.Payload as CommentCreated; var commentCreated = (CommentCreated)@event.Payload;
if (commentCreated?.Mentions?.Length > 0) if (commentCreated.Mentions?.Length > 0)
{ {
var users = await userResolver.QueryManyAsync(commentCreated.Mentions); var users = await userResolver.QueryManyAsync(commentCreated.Mentions);
if (users.Count > 0) if (users.Count > 0)
{ {
var result = new List<EnrichedEvent>();
foreach (var user in users.Values) foreach (var user in users.Values)
{ {
var enrichedEvent = new EnrichedCommentEvent var enrichedEvent = new EnrichedCommentEvent
@ -59,18 +66,16 @@ namespace Squidex.Domain.Apps.Entities.Comments
SimpleMapper.Map(commentCreated, enrichedEvent); SimpleMapper.Map(commentCreated, enrichedEvent);
result.Add(enrichedEvent); yield return enrichedEvent;
} }
return result;
} }
} }
return EmptyResult;
} }
protected override bool Trigger(EnrichedCommentEvent @event, CommentTrigger trigger) public bool Trigger(EnrichedEvent @event, RuleContext context)
{ {
var trigger = (CommentTrigger)context.Rule.Trigger;
if (string.IsNullOrWhiteSpace(trigger.Condition)) if (string.IsNullOrWhiteSpace(trigger.Condition))
{ {
return true; return true;

59
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs

@ -5,15 +5,18 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Runtime.CompilerServices;
using System.Threading;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
@ -22,13 +25,20 @@ using Squidex.Text;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
{ {
public sealed class ContentChangedTriggerHandler : RuleTriggerHandler<ContentChangedTriggerV2, ContentEvent, EnrichedContentEvent> public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler
{ {
private readonly IScriptEngine scriptEngine; private readonly IScriptEngine scriptEngine;
private readonly IContentLoader contentLoader; private readonly IContentLoader contentLoader;
private readonly IContentRepository contentRepository; private readonly IContentRepository contentRepository;
public override bool CanCreateSnapshotEvents => true; public bool CanCreateSnapshotEvents => true;
public Type TriggerType => typeof(ContentChangedTriggerV2);
public bool Handles(AppEvent appEvent)
{
return appEvent is ContentEvent;
}
public ContentChangedTriggerHandler( public ContentChangedTriggerHandler(
IScriptEngine scriptEngine, IScriptEngine scriptEngine,
@ -44,14 +54,17 @@ namespace Squidex.Domain.Apps.Entities.Contents
this.contentRepository = contentRepository; this.contentRepository = contentRepository;
} }
public override async IAsyncEnumerable<EnrichedEvent> CreateSnapshotEvents(ContentChangedTriggerV2 trigger, DomainId appId) public async IAsyncEnumerable<EnrichedEvent> CreateSnapshotEventsAsync(RuleContext context,
[EnumeratorCancellation] CancellationToken ct = default)
{ {
var trigger = (ContentChangedTriggerV2)context.Rule.Trigger;
var schemaIds = var schemaIds =
trigger.Schemas?.Count > 0 ? trigger.Schemas?.Count > 0 ?
trigger.Schemas.Select(x => x.SchemaId).Distinct().ToHashSet() : trigger.Schemas.Select(x => x.SchemaId).Distinct().ToHashSet() :
null; null;
await foreach (var content in contentRepository.StreamAll(appId, schemaIds)) await foreach (var content in contentRepository.StreamAll(context.AppId.Id, schemaIds, ct))
{ {
var result = new EnrichedContentEvent var result = new EnrichedContentEvent
{ {
@ -61,20 +74,23 @@ namespace Squidex.Domain.Apps.Entities.Contents
SimpleMapper.Map(content, result); SimpleMapper.Map(content, result);
result.Actor = content.LastModifiedBy; result.Actor = content.LastModifiedBy;
result.Name = $"{content.SchemaId.Name.ToPascalCase()}CreatedFromSnapshot"; result.Name = $"ContentQueried({content.SchemaId.Name.ToPascalCase()})";
yield return result; yield return result;
} }
} }
protected override async Task<EnrichedContentEvent?> CreateEnrichedEventAsync(Envelope<ContentEvent> @event) public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
[EnumeratorCancellation] CancellationToken ct = default)
{ {
var contentEvent = (ContentEvent)@event.Payload;
var result = new EnrichedContentEvent(); var result = new EnrichedContentEvent();
var content = var content =
await contentLoader.GetAsync( await contentLoader.GetAsync(
@event.Payload.AppId.Id, contentEvent.AppId.Id,
@event.Payload.ContentId, contentEvent.ContentId,
@event.Headers.EventStreamNumber()); @event.Headers.EventStreamNumber());
if (content != null) if (content != null)
@ -131,13 +147,20 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
} }
result.Name = $"{@event.Payload.SchemaId.Name.ToPascalCase()}{result.Type}"; yield return result;
}
return result; public string? GetName(AppEvent @event)
{
var contentEvent = (ContentEvent)@event;
return $"{@event.GetType().Name}({contentEvent.SchemaId.Name.ToPascalCase()})";
} }
protected override bool Trigger(ContentEvent @event, ContentChangedTriggerV2 trigger, DomainId ruleId) public bool Trigger(Envelope<AppEvent> @event, RuleContext context)
{ {
var trigger = (ContentChangedTriggerV2)context.Rule.Trigger;
if (trigger.HandleAll) if (trigger.HandleAll)
{ {
return true; return true;
@ -145,9 +168,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (trigger.Schemas != null) if (trigger.Schemas != null)
{ {
var contentEvent = (ContentEvent)@event.Payload;
foreach (var schema in trigger.Schemas) foreach (var schema in trigger.Schemas)
{ {
if (MatchsSchema(schema, @event.SchemaId)) if (MatchsSchema(schema, contentEvent.SchemaId))
{ {
return true; return true;
} }
@ -157,8 +182,10 @@ namespace Squidex.Domain.Apps.Entities.Contents
return false; return false;
} }
protected override bool Trigger(EnrichedContentEvent @event, ContentChangedTriggerV2 trigger) public bool Trigger(EnrichedEvent @event, RuleContext context)
{ {
var trigger = (ContentChangedTriggerV2)context.Rule.Trigger;
if (trigger.HandleAll) if (trigger.HandleAll)
{ {
return true; return true;
@ -166,9 +193,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (trigger.Schemas != null) if (trigger.Schemas != null)
{ {
var contentEvent = (EnrichedContentEvent)@event;
foreach (var schema in trigger.Schemas) foreach (var schema in trigger.Schemas)
{ {
if (MatchsSchema(schema, @event.SchemaId) && MatchsCondition(schema, @event)) if (MatchsSchema(schema, contentEvent.SchemaId) && MatchsCondition(schema, contentEvent))
{ {
return true; return true;
} }

3
backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs

@ -7,6 +7,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
@ -19,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories
{ {
public interface IContentRepository public interface IContentRepository
{ {
IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds); IAsyncEnumerable<IContentEntity> StreamAll(DomainId appId, HashSet<DomainId>? schemaIds, CancellationToken ct);
Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> schemas, Q q, SearchScope scope); Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, List<ISchemaEntity> schemas, Q q, SearchScope scope);

2
backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs

@ -32,5 +32,7 @@ namespace Squidex.Domain.Apps.Entities
Task<List<ISchemaEntity>> GetSchemasAsync(DomainId appId); Task<List<ISchemaEntity>> GetSchemasAsync(DomainId appId);
Task<List<IRuleEntity>> GetRulesAsync(DomainId appId); Task<List<IRuleEntity>> GetRulesAsync(DomainId appId);
Task<IRuleEntity?> GetRuleAsync(DomainId appId, DomainId id);
} }
} }

5
backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs

@ -114,7 +114,10 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject
private async Task Trigger(TriggerRule command) private async Task Trigger(TriggerRule command)
{ {
var @event = SimpleMapper.Map(command, new RuleManuallyTriggered { RuleId = Snapshot.Id, AppId = Snapshot.AppId }); var @event = new RuleManuallyTriggered();
SimpleMapper.Map(command, @event);
SimpleMapper.Map(Snapshot, @event);
await ruleEnqueuer.EnqueueAsync(Snapshot.RuleDef, Snapshot.Id, Envelope.Create(@event)); await ruleEnqueuer.EnqueueAsync(Snapshot.RuleDef, Snapshot.Id, Envelope.Create(@event));
} }

30
backend/src/Squidex.Domain.Apps.Entities/Rules/ManualTriggerHandler.cs

@ -5,33 +5,45 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Rules; using Squidex.Domain.Apps.Events.Rules;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Rules namespace Squidex.Domain.Apps.Entities.Rules
{ {
public sealed class ManualTriggerHandler : RuleTriggerHandler<ManualTrigger, RuleManuallyTriggered, EnrichedManualEvent> public sealed class ManualTriggerHandler : IRuleTriggerHandler
{ {
protected override Task<EnrichedManualEvent?> CreateEnrichedEventAsync(Envelope<RuleManuallyTriggered> @event) public Type TriggerType => typeof(ManualTrigger);
public bool Handles(AppEvent appEvent)
{ {
var result = new EnrichedManualEvent return appEvent is RuleManuallyTriggered;
{ }
Name = "Manual"
}; public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
[EnumeratorCancellation] CancellationToken ct)
{
var result = new EnrichedManualEvent();
SimpleMapper.Map(@event.Payload, result); SimpleMapper.Map(@event.Payload, result);
return Task.FromResult<EnrichedManualEvent?>(result); await Task.Yield();
yield return result;
} }
protected override bool Trigger(EnrichedManualEvent @event, ManualTrigger trigger) public string? GetName(AppEvent @event)
{ {
return true; return "Manual";
} }
} }
} }

16
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs

@ -56,11 +56,21 @@ namespace Squidex.Domain.Apps.Entities.Rules
Guard.NotNull(rule, nameof(rule)); Guard.NotNull(rule, nameof(rule));
Guard.NotNull(@event, nameof(@event)); Guard.NotNull(@event, nameof(@event));
var jobs = await ruleService.CreateJobsAsync(rule, ruleId, @event); var ruleContext = new RuleContext
{
Rule = rule,
RuleId = ruleId,
IgnoreStale = false
};
var jobs = ruleService.CreateJobsAsync(@event, ruleContext);
foreach (var (job, ex) in jobs) await foreach (var (job, ex, _) in jobs)
{ {
await ruleEventRepository.EnqueueAsync(job, ex); if (job != null)
{
await ruleEventRepository.EnqueueAsync(job, ex);
}
} }
} }

128
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs

@ -0,0 +1,128 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Orleans;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Rules.Runner
{
public sealed class DefaultRuleRunnerService : IRuleRunnerService
{
private const int MaxSimulatedEvents = 100;
private readonly IEventDataFormatter eventDataFormatter;
private readonly IEventStore eventStore;
private readonly IGrainFactory grainFactory;
private readonly IRuleService ruleService;
public DefaultRuleRunnerService(IGrainFactory grainFactory,
IEventStore eventStore,
IEventDataFormatter eventDataFormatter,
IRuleService ruleService)
{
Guard.NotNull(grainFactory, nameof(grainFactory));
Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter));
Guard.NotNull(eventStore, nameof(eventStore));
Guard.NotNull(ruleService, nameof(ruleService));
this.grainFactory = grainFactory;
this.eventDataFormatter = eventDataFormatter;
this.eventStore = eventStore;
this.ruleService = ruleService;
}
public async Task<List<SimulatedRuleEvent>> SimulateAsync(IRuleEntity rule, CancellationToken ct)
{
Guard.NotNull(rule, nameof(rule));
var context = GetContext(rule);
var result = new List<SimulatedRuleEvent>(MaxSimulatedEvents);
await foreach (var storedEvent in eventStore.QueryAllReverseAsync($"^([a-z]+)\\-{rule.AppId.Id}", null, MaxSimulatedEvents, ct))
{
var @event = eventDataFormatter.ParseIfKnown(storedEvent);
if (@event?.Payload is AppEvent appEvent)
{
await foreach (var (job, exception, skip) in ruleService.CreateJobsAsync(@event, context, ct))
{
var name = job?.EventName;
if (string.IsNullOrWhiteSpace(name))
{
name = ruleService.GetName(appEvent);
}
var simulationResult = new SimulatedRuleEvent(
name,
job?.ActionName,
job?.ActionData,
exception?.Message,
skip);
result.Add(simulationResult);
}
}
}
return result;
}
public Task CancelAsync(DomainId appId)
{
var grain = grainFactory.GetGrain<IRuleRunnerGrain>(appId.ToString());
return grain.CancelAsync();
}
public bool CanRunRule(IRuleEntity rule)
{
var context = GetContext(rule);
return context.Rule.IsEnabled && context.Rule.Trigger is not ManualTrigger;
}
public bool CanRunFromSnapshots(IRuleEntity rule)
{
var context = GetContext(rule);
return CanRunRule(rule) && ruleService.CanCreateSnapshotEvents(context);
}
public Task<DomainId?> GetRunningRuleIdAsync(DomainId appId)
{
var grain = grainFactory.GetGrain<IRuleRunnerGrain>(appId.ToString());
return grain.GetRunningRuleIdAsync();
}
public Task RunAsync(DomainId appId, DomainId ruleId, bool fromSnapshots = false)
{
var grain = grainFactory.GetGrain<IRuleRunnerGrain>(appId.ToString());
return grain.RunAsync(ruleId, fromSnapshots);
}
private static RuleContext GetContext(IRuleEntity rule)
{
return new RuleContext
{
AppId = rule.AppId,
Rule = rule.RuleDef,
RuleId = rule.Id,
IgnoreStale = true
};
}
}
}

61
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/GrainRuleRunnerService.cs

@ -1,61 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Orleans;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Rules.Runner
{
public sealed class GrainRuleRunnerService : IRuleRunnerService
{
private readonly IGrainFactory grainFactory;
private readonly IRuleService ruleService;
public GrainRuleRunnerService(IGrainFactory grainFactory, IRuleService ruleService)
{
Guard.NotNull(grainFactory, nameof(grainFactory));
Guard.NotNull(ruleService, nameof(ruleService));
this.grainFactory = grainFactory;
this.ruleService = ruleService;
}
public Task CancelAsync(DomainId appId)
{
var grain = grainFactory.GetGrain<IRuleRunnerGrain>(appId.ToString());
return grain.CancelAsync();
}
public bool CanRunRule(IRuleEntity rule)
{
return rule.RuleDef.IsEnabled && rule.RuleDef.Trigger is not ManualTrigger;
}
public bool CanRunFromSnapshots(IRuleEntity rule)
{
return CanRunRule(rule) && ruleService.CanCreateSnapshotEvents(rule.RuleDef);
}
public Task<DomainId?> GetRunningRuleIdAsync(DomainId appId)
{
var grain = grainFactory.GetGrain<IRuleRunnerGrain>(appId.ToString());
return grain.GetRunningRuleIdAsync();
}
public Task RunAsync(DomainId appId, DomainId ruleId, bool fromSnapshots = false)
{
var grain = grainFactory.GetGrain<IRuleRunnerGrain>(appId.ToString());
return grain.RunAsync(ruleId, fromSnapshots);
}
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs

@ -5,6 +5,8 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -12,6 +14,8 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
{ {
public interface IRuleRunnerService public interface IRuleRunnerService
{ {
Task<List<SimulatedRuleEvent>> SimulateAsync(IRuleEntity rule, CancellationToken ct);
Task RunAsync(DomainId appId, DomainId ruleId, bool fromSnapshots = false); Task RunAsync(DomainId appId, DomainId ruleId, bool fromSnapshots = false);
Task CancelAsync(DomainId appId); Task CancelAsync(DomainId appId);

47
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerGrain.cs

@ -47,7 +47,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
public string? Position { get; set; } public string? Position { get; set; }
public bool FromSnapshots { get; set; } public bool RunFromSnapshots { get; set; }
} }
public RuleRunnerGrain( public RuleRunnerGrain(
@ -122,7 +122,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
state.Value = new State state.Value = new State
{ {
RuleId = ruleId, RuleId = ruleId,
FromSnapshots = fromSnapshots RunFromSnapshots = fromSnapshots
}; };
await EnsureIsRunningAsync(false); await EnsureIsRunningAsync(false);
@ -136,7 +136,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
if (job.RuleId != null && currentJobToken == null) if (job.RuleId != null && currentJobToken == null)
{ {
if (state.Value.FromSnapshots && continues) if (state.Value.RunFromSnapshots && continues)
{ {
state.Value = new State(); state.Value = new State();
@ -162,24 +162,30 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
{ {
currentReminder = await RegisterOrUpdateReminder("KeepAlive", TimeSpan.Zero, TimeSpan.FromMinutes(2)); currentReminder = await RegisterOrUpdateReminder("KeepAlive", TimeSpan.Zero, TimeSpan.FromMinutes(2));
var rules = await appProvider.GetRulesAsync(DomainId.Create(Key)); var rule = await appProvider.GetRuleAsync(DomainId.Create(Key), currentState.RuleId!.Value);
var rule = rules.Find(x => x.Id == currentState.RuleId);
if (rule == null) if (rule == null)
{ {
throw new InvalidOperationException("Cannot find rule."); throw new DomainObjectNotFoundException(currentState.RuleId.ToString()!);
} }
using (localCache.StartContext()) using (localCache.StartContext())
{ {
if (currentState.FromSnapshots && ruleService.CanCreateSnapshotEvents(rule.RuleDef)) var context = new RuleContext
{
AppId = rule.AppId,
Rule = rule.RuleDef,
RuleId = rule.Id,
IgnoreStale = true
};
if (currentState.RunFromSnapshots && ruleService.CanCreateSnapshotEvents(context))
{ {
await EnqueueFromSnapshotsAsync(rule); await EnqueueFromSnapshotsAsync(context, ct);
} }
else else
{ {
await EnqueueFromEventsAsync(currentState, rule, ct); await EnqueueFromEventsAsync(currentState, context, ct);
} }
} }
} }
@ -216,11 +222,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
} }
} }
private async Task EnqueueFromSnapshotsAsync(IRuleEntity rule) private async Task EnqueueFromSnapshotsAsync(RuleContext context, CancellationToken ct)
{ {
var errors = 0; var errors = 0;
await foreach (var (job, ex) in ruleService.CreateSnapshotJobsAsync(rule.RuleDef, rule.Id, rule.AppId.Id)) await foreach (var (job, ex, _) in ruleService.CreateSnapshotJobsAsync(context, ct))
{ {
if (job != null) if (job != null)
{ {
@ -242,11 +248,13 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
} }
} }
private async Task EnqueueFromEventsAsync(State currentState, IRuleEntity rule, CancellationToken ct) private async Task EnqueueFromEventsAsync(State currentState, RuleContext context, CancellationToken ct)
{ {
var errors = 0; var errors = 0;
await eventStore.QueryAsync(async storedEvent => var filter = $"^([a-z]+)\\-{Key}";
await foreach (var storedEvent in eventStore.QueryAllAsync(filter, currentState.Position, ct: ct))
{ {
try try
{ {
@ -254,11 +262,14 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
if (@event != null) if (@event != null)
{ {
var jobs = await ruleService.CreateJobsAsync(rule.RuleDef, rule.Id, @event, false); var jobs = ruleService.CreateJobsAsync(@event, context, ct);
foreach (var (job, ex) in jobs) await foreach (var (job, ex, _) in jobs)
{ {
await ruleEventRepository.EnqueueAsync(job, ex); if (job != null)
{
await ruleEventRepository.EnqueueAsync(job, ex);
}
} }
} }
} }
@ -281,7 +292,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Runner
} }
await state.WriteAsync(); await state.WriteAsync();
}, $"^([a-z]+)\\-{Key}", currentState.Position, ct); }
} }
public Task ReceiveReminder(string reminderName, TickStatus status) public Task ReceiveReminder(string reminderName, TickStatus status)

22
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/SimulatedRuleEvent.cs

@ -0,0 +1,22 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.HandleRules;
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Domain.Apps.Entities.Rules.Runner
{
public sealed record SimulatedRuleEvent(
string EventName,
string? ActionName,
string? ActionData,
string? Error,
SkipReason SkipReason)
{
}
}

34
backend/src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTriggerHandler.cs

@ -5,6 +5,10 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
@ -14,25 +18,41 @@ using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
{ {
public sealed class UsageTriggerHandler : RuleTriggerHandler<UsageTrigger, AppUsageExceeded, EnrichedUsageExceededEvent> public sealed class UsageTriggerHandler : IRuleTriggerHandler
{ {
private const string EventName = "Usage exceeded"; private const string EventName = "Usage exceeded";
protected override Task<EnrichedUsageExceededEvent?> CreateEnrichedEventAsync(Envelope<AppUsageExceeded> @event) public Type TriggerType => typeof(UsageTrigger);
public bool Handles(AppEvent appEvent)
{
return appEvent is AppUsageExceeded;
}
public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
[EnumeratorCancellation] CancellationToken ct)
{ {
var usageEvent = (AppUsageExceeded)@event.Payload;
var result = new EnrichedUsageExceededEvent var result = new EnrichedUsageExceededEvent
{ {
CallsCurrent = @event.Payload.CallsCurrent, CallsCurrent = usageEvent.CallsCurrent,
CallsLimit = @event.Payload.CallsLimit, CallsLimit = usageEvent.CallsLimit,
Name = EventName Name = EventName
}; };
return Task.FromResult<EnrichedUsageExceededEvent?>(result); await Task.Yield();
yield return result;
} }
protected override bool Trigger(EnrichedUsageExceededEvent @event, UsageTrigger trigger) public bool Trigger(Envelope<AppEvent> @event, RuleContext context)
{ {
return @event.CallsLimit == trigger.Limit; var trigger = (UsageTrigger)context.Rule.Trigger;
var usageEvent = (AppUsageExceeded)@event.Payload;
return usageEvent.CallsLimit >= trigger.Limit;
} }
} }
} }

32
backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaChangedTriggerHandler.cs

@ -5,6 +5,10 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
@ -18,10 +22,12 @@ using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Schemas namespace Squidex.Domain.Apps.Entities.Schemas
{ {
public sealed class SchemaChangedTriggerHandler : RuleTriggerHandler<SchemaChangedTrigger, SchemaEvent, EnrichedSchemaEvent> public sealed class SchemaChangedTriggerHandler : IRuleTriggerHandler
{ {
private readonly IScriptEngine scriptEngine; private readonly IScriptEngine scriptEngine;
public Type TriggerType => typeof(SchemaChangedTrigger);
public SchemaChangedTriggerHandler(IScriptEngine scriptEngine) public SchemaChangedTriggerHandler(IScriptEngine scriptEngine)
{ {
Guard.NotNull(scriptEngine, nameof(scriptEngine)); Guard.NotNull(scriptEngine, nameof(scriptEngine));
@ -29,9 +35,15 @@ namespace Squidex.Domain.Apps.Entities.Schemas
this.scriptEngine = scriptEngine; this.scriptEngine = scriptEngine;
} }
protected override Task<EnrichedSchemaEvent?> CreateEnrichedEventAsync(Envelope<SchemaEvent> @event) public bool Handles(AppEvent appEvent)
{
return appEvent is SchemaEvent;
}
public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
[EnumeratorCancellation] CancellationToken ct)
{ {
EnrichedSchemaEvent? result = new EnrichedSchemaEvent(); var result = new EnrichedSchemaEvent();
SimpleMapper.Map(@event.Payload, result); SimpleMapper.Map(@event.Payload, result);
@ -57,20 +69,18 @@ namespace Squidex.Domain.Apps.Entities.Schemas
result.Type = EnrichedSchemaEventType.Deleted; result.Type = EnrichedSchemaEventType.Deleted;
break; break;
default: default:
result = null; yield break;
break;
} }
if (result != null) await Task.Yield();
{
result.Name = $"Schema{result.Type}";
}
return Task.FromResult(result); yield return result;
} }
protected override bool Trigger(EnrichedSchemaEvent @event, SchemaChangedTrigger trigger) public bool Trigger(EnrichedEvent @event, RuleContext context)
{ {
var trigger = (SchemaChangedTrigger)context.Rule.Trigger;
if (string.IsNullOrWhiteSpace(trigger.Condition)) if (string.IsNullOrWhiteSpace(trigger.Condition))
{ {
return true; return true;

134
backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs

@ -8,6 +8,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Hosting; using Squidex.Hosting;
@ -47,34 +48,20 @@ namespace Squidex.Infrastructure.EventSourcing
var result = new List<StoredEvent>(); var result = new List<StoredEvent>();
await documentClient.QueryAsync(collectionUri, query, commit => await foreach (var commit in documentClient.QueryAsync(collectionUri, query, default))
{ {
var eventStreamOffset = (int)commit.EventStreamOffset; foreach (var storedEvent in commit.Filtered().Reverse())
var commitTimestamp = commit.Timestamp;
var commitOffset = 0;
foreach (var @event in commit.Events)
{ {
eventStreamOffset++; result.Add(storedEvent);
var eventData = @event.ToEventData(); if (result.Count == count)
var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); {
break;
result.Add(new StoredEvent(streamName, eventToken, eventStreamOffset, eventData)); }
} }
return Task.CompletedTask;
});
IEnumerable<StoredEvent> ordered = result.OrderBy(x => x.EventStreamNumber);
if (result.Count > count)
{
ordered = ordered.Skip(result.Count - count);
} }
return ordered.ToList(); return result;
} }
} }
@ -90,72 +77,89 @@ namespace Squidex.Infrastructure.EventSourcing
var result = new List<StoredEvent>(); var result = new List<StoredEvent>();
await documentClient.QueryAsync(collectionUri, query, commit => await foreach (var commit in documentClient.QueryAsync(collectionUri, query, default))
{ {
var eventStreamOffset = (int)commit.EventStreamOffset; foreach (var storedEvent in commit.Filtered().Reverse())
var commitTimestamp = commit.Timestamp;
var commitOffset = 0;
foreach (var @event in commit.Events)
{ {
eventStreamOffset++; result.Add(storedEvent);
if (eventStreamOffset >= streamPosition)
{
var eventData = @event.ToEventData();
var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length);
result.Add(new StoredEvent(streamName, eventToken, eventStreamOffset, eventData));
}
} }
}
return Task.CompletedTask;
});
return result; return result;
} }
} }
public async Task QueryAsync(Func<StoredEvent, Task> callback, string? streamFilter = null, string? position = null, CancellationToken ct = default) public async IAsyncEnumerable<StoredEvent> QueryAllAsync( string? streamFilter = null, string? position = null, long take = long.MaxValue,
[EnumeratorCancellation] CancellationToken ct = default)
{ {
Guard.NotNull(callback, nameof(callback));
ThrowIfDisposed(); ThrowIfDisposed();
if (take <= 0)
{
yield break;
}
StreamPosition lastPosition = position; StreamPosition lastPosition = position;
var filterDefinition = FilterBuilder.CreateByFilter(streamFilter, lastPosition); var filterDefinition = FilterBuilder.CreateByFilter(streamFilter, lastPosition, "ASC", take);
var filterExpression = FilterBuilder.CreateExpression(null, null);
using (Profiler.TraceMethod<CosmosDbEventStore>()) var taken = int.MaxValue;
await foreach (var commit in documentClient.QueryAsync(collectionUri, filterDefinition, ct: ct))
{ {
await documentClient.QueryAsync(collectionUri, filterDefinition, async commit => if (taken == take)
{ {
var eventStreamOffset = (int)commit.EventStreamOffset; yield break;
}
var commitTimestamp = commit.Timestamp;
var commitOffset = 0;
foreach (var @event in commit.Events) foreach (var storedEvent in commit.Filtered(lastPosition))
{
if (taken == take)
{ {
eventStreamOffset++; yield break;
}
if (commitOffset > lastPosition.CommitOffset || commitTimestamp > lastPosition.Timestamp) yield return storedEvent;
{
var eventData = @event.ToEventData();
if (filterExpression(eventData)) taken++;
{ }
var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); }
}
await callback(new StoredEvent(commit.EventStream, eventToken, eventStreamOffset, eventData)); public async IAsyncEnumerable<StoredEvent> QueryAllReverseAsync(string? streamFilter = null, string? position = null, long take = long.MaxValue,
} [EnumeratorCancellation] CancellationToken ct = default)
} {
ThrowIfDisposed();
if (take <= 0)
{
yield break;
}
commitOffset++; StreamPosition lastPosition = position;
var filterDefinition = FilterBuilder.CreateByFilter(streamFilter, lastPosition, "DESC", take);
var taken = long.MaxValue;
await foreach (var commit in documentClient.QueryAsync(collectionUri, filterDefinition, ct: ct))
{
if (taken == take)
{
yield break;
}
foreach (var storedEvent in commit.Filtered(lastPosition))
{
if (taken == take)
{
yield break;
} }
}, ct);
yield return storedEvent;
taken++;
}
} }
} }
} }

8
backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Writer.cs

@ -21,7 +21,7 @@ namespace Squidex.Infrastructure.EventSourcing
private const int MaxWriteAttempts = 20; private const int MaxWriteAttempts = 20;
private const int MaxCommitSize = 10; private const int MaxCommitSize = 10;
public Task DeleteStreamAsync(string streamName) public async Task DeleteStreamAsync(string streamName)
{ {
Guard.NotNullOrEmpty(streamName, nameof(streamName)); Guard.NotNullOrEmpty(streamName, nameof(streamName));
@ -34,12 +34,12 @@ namespace Squidex.Infrastructure.EventSourcing
PartitionKey = new PartitionKey(streamName) PartitionKey = new PartitionKey(streamName)
}; };
return documentClient.QueryAsync(collectionUri, query, commit => await foreach (var commit in documentClient.QueryAsync(collectionUri, query))
{ {
var documentUri = UriFactory.CreateDocumentUri(DatabaseId, Constants.Collection, commit.Id.ToString()); var documentUri = UriFactory.CreateDocumentUri(DatabaseId, Constants.Collection, commit.Id.ToString());
return documentClient.DeleteDocumentAsync(documentUri, deleteOptions); await documentClient.DeleteDocumentAsync(documentUri, deleteOptions);
}); }
} }
public Task AppendAsync(Guid commitId, string streamName, ICollection<EventData> events) public Task AppendAsync(Guid commitId, string streamName, ICollection<EventData> events)

46
backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterBuilder.cs

@ -7,7 +7,6 @@
using System.Collections.Generic; using System.Collections.Generic;
using Microsoft.Azure.Documents; using Microsoft.Azure.Documents;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Infrastructure.EventSourcing namespace Squidex.Infrastructure.EventSourcing
{ {
@ -70,10 +69,10 @@ namespace Squidex.Infrastructure.EventSourcing
return new SqlQuerySpec(query, parameters); return new SqlQuerySpec(query, parameters);
} }
public static SqlQuerySpec ByStreamNameDesc(string streamName, long count) public static SqlQuerySpec ByStreamNameDesc(string streamName, long take)
{ {
var query = var query =
$"SELECT TOP {count}* " + $"SELECT TOP {take}* " +
$"FROM {Constants.Collection} e " + $"FROM {Constants.Collection} e " +
$"WHERE " + $"WHERE " +
$" e.eventStream = @name " + $" e.eventStream = @name " +
@ -87,19 +86,7 @@ namespace Squidex.Infrastructure.EventSourcing
return new SqlQuerySpec(query, parameters); return new SqlQuerySpec(query, parameters);
} }
public static SqlQuerySpec CreateByProperty(string property, object value, StreamPosition streamPosition) public static SqlQuerySpec CreateByFilter(string? streamFilter, StreamPosition streamPosition, string sortOrder, long take)
{
var filters = new List<string>();
var parameters = new SqlParameterCollection();
filters.ForPosition(parameters, streamPosition);
filters.ForProperty(parameters, property, value);
return BuildQuery(filters, parameters);
}
public static SqlQuerySpec CreateByFilter(string? streamFilter, StreamPosition streamPosition)
{ {
var filters = new List<string>(); var filters = new List<string>();
@ -108,23 +95,16 @@ namespace Squidex.Infrastructure.EventSourcing
filters.ForPosition(parameters, streamPosition); filters.ForPosition(parameters, streamPosition);
filters.ForRegex(parameters, streamFilter); filters.ForRegex(parameters, streamFilter);
return BuildQuery(filters, parameters); return BuildQuery(filters, parameters, sortOrder, take);
} }
private static SqlQuerySpec BuildQuery(IEnumerable<string> filters, SqlParameterCollection parameters) private static SqlQuerySpec BuildQuery(IEnumerable<string> filters, SqlParameterCollection parameters, string sortOrder, long take)
{ {
var query = $"SELECT * FROM {Constants.Collection} e WHERE {string.Join(" AND ", filters)} ORDER BY e.timestamp"; var query = $"SELECT TOP {take} * FROM {Constants.Collection} e WHERE {string.Join(" AND ", filters)} ORDER BY e.timestamp {sortOrder}";
return new SqlQuerySpec(query, parameters); return new SqlQuerySpec(query, parameters);
} }
private static void ForProperty(this ICollection<string> filters, SqlParameterCollection parameters, string property, object value)
{
filters.Add($"ARRAY_CONTAINS(e.events, {{ \"header\": {{ \"{property}\": @value }} }}, true)");
parameters.Add(new SqlParameter("@value", value));
}
private static void ForRegex(this ICollection<string> filters, SqlParameterCollection parameters, string? streamFilter) private static void ForRegex(this ICollection<string> filters, SqlParameterCollection parameters, string? streamFilter)
{ {
if (!StreamFilter.IsAll(streamFilter)) if (!StreamFilter.IsAll(streamFilter))
@ -155,19 +135,5 @@ namespace Squidex.Infrastructure.EventSourcing
parameters.Add(new SqlParameter("@time", streamPosition.Timestamp)); parameters.Add(new SqlParameter("@time", streamPosition.Timestamp));
} }
public static EventPredicate CreateExpression(string? property, object? value)
{
if (!string.IsNullOrWhiteSpace(property))
{
var jsonValue = JsonValue.Create(value);
return x => x.Headers.TryGetValue(property, out var p) && p.Equals(jsonValue);
}
else
{
return x => true;
}
}
} }
} }

62
backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterExtensions.cs

@ -6,7 +6,9 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Azure.Documents; using Microsoft.Azure.Documents;
@ -39,29 +41,71 @@ namespace Squidex.Infrastructure.EventSourcing
return default!; return default!;
} }
public static Task QueryAsync(this DocumentClient documentClient, Uri collectionUri, SqlQuerySpec querySpec, Func<CosmosDbEventCommit, Task> handler, CancellationToken ct = default) public static async IAsyncEnumerable<CosmosDbEventCommit> QueryAsync(this DocumentClient documentClient, Uri collectionUri, SqlQuerySpec querySpec,
[EnumeratorCancellation] CancellationToken ct = default)
{ {
var query = documentClient.CreateDocumentQuery<CosmosDbEventCommit>(collectionUri, querySpec, CrossPartition); var query = documentClient.CreateDocumentQuery<CosmosDbEventCommit>(collectionUri, querySpec, CrossPartition);
return query.QueryAsync(handler, ct); var documentQuery = query.AsDocumentQuery();
}
public static async Task QueryAsync<T>(this IQueryable<T> queryable, Func<T, Task> handler, CancellationToken ct = default)
{
var documentQuery = queryable.AsDocumentQuery();
using (documentQuery) using (documentQuery)
{ {
while (documentQuery.HasMoreResults && !ct.IsCancellationRequested) while (documentQuery.HasMoreResults && !ct.IsCancellationRequested)
{ {
var items = await documentQuery.ExecuteNextAsync<T>(ct); var items = await documentQuery.ExecuteNextAsync<CosmosDbEventCommit>(ct);
foreach (var item in items) foreach (var item in items)
{ {
await handler(item); yield return item;
} }
} }
} }
} }
public static IEnumerable<StoredEvent> Filtered(this CosmosDbEventCommit commit, StreamPosition lastPosition)
{
var eventStreamOffset = commit.EventStreamOffset;
var commitTimestamp = commit.Timestamp;
var commitOffset = 0;
foreach (var @event in commit.Events)
{
eventStreamOffset++;
if (commitOffset > lastPosition.CommitOffset || commitTimestamp > lastPosition.Timestamp)
{
var eventData = @event.ToEventData();
var eventPosition = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length);
yield return new StoredEvent(commit.EventStream, eventPosition, eventStreamOffset, eventData);
}
commitOffset++;
}
}
public static IEnumerable<StoredEvent> Filtered(this CosmosDbEventCommit commit, long streamPosition = EtagVersion.Empty)
{
var eventStreamOffset = commit.EventStreamOffset;
var commitTimestamp = commit.Timestamp;
var commitOffset = 0;
foreach (var @event in commit.Events)
{
eventStreamOffset++;
if (eventStreamOffset >= streamPosition)
{
var eventData = @event.ToEventData();
var eventPosition = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length);
yield return new StoredEvent(commit.EventStream, eventPosition, eventStreamOffset, eventData);
}
commitOffset++;
}
}
} }
} }

153
backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs

@ -8,6 +8,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using EventStore.ClientAPI; using EventStore.ClientAPI;
@ -26,7 +27,7 @@ namespace Squidex.Infrastructure.EventSourcing
private static readonly IReadOnlyList<StoredEvent> EmptyEvents = new List<StoredEvent>(); private static readonly IReadOnlyList<StoredEvent> EmptyEvents = new List<StoredEvent>();
private readonly IEventStoreConnection connection; private readonly IEventStoreConnection connection;
private readonly IJsonSerializer serializer; private readonly IJsonSerializer serializer;
private readonly string prefix; private readonly string prefix = "squidex";
private readonly ProjectionClient projectionClient; private readonly ProjectionClient projectionClient;
public GetEventStore(IEventStoreConnection connection, IJsonSerializer serializer, string prefix, string projectionHost) public GetEventStore(IEventStoreConnection connection, IJsonSerializer serializer, string prefix, string projectionHost)
@ -37,9 +38,12 @@ namespace Squidex.Infrastructure.EventSourcing
this.connection = connection; this.connection = connection;
this.serializer = serializer; this.serializer = serializer;
this.prefix = prefix.Trim(' ', '-').Or("squidex"); if (!string.IsNullOrWhiteSpace(prefix))
{
this.prefix = prefix.Trim(' ', '-');
}
projectionClient = new ProjectionClient(connection, prefix, projectionHost); projectionClient = new ProjectionClient(connection, this.prefix, projectionHost);
} }
public async Task InitializeAsync(CancellationToken ct = default) public async Task InitializeAsync(CancellationToken ct = default)
@ -65,40 +69,40 @@ namespace Squidex.Infrastructure.EventSourcing
return new GetEventStoreSubscription(connection, subscriber, serializer, projectionClient, position, prefix, streamFilter); return new GetEventStoreSubscription(connection, subscriber, serializer, projectionClient, position, prefix, streamFilter);
} }
public async Task QueryAsync(Func<StoredEvent, Task> callback, string? streamFilter = null, string? position = null, CancellationToken ct = default) public async IAsyncEnumerable<StoredEvent> QueryAllAsync(string? streamFilter = null, string? position = null, long take = long.MaxValue,
[EnumeratorCancellation] CancellationToken ct = default)
{ {
Guard.NotNull(callback, nameof(callback)); if (take <= 0)
using (Profiler.TraceMethod<GetEventStore>())
{ {
var streamName = await projectionClient.CreateProjectionAsync(streamFilter); yield break;
}
var sliceStart = ProjectionClient.ParsePosition(position); var streamName = await projectionClient.CreateProjectionAsync(streamFilter);
await QueryAsync(callback, streamName, sliceStart, ct); var sliceStart = ProjectionClient.ParsePosition(position);
await foreach (var storedEvent in QueryReverseAsync(streamName, sliceStart, take, ct))
{
yield return storedEvent;
} }
} }
private async Task QueryAsync(Func<StoredEvent, Task> callback, string streamName, long sliceStart, CancellationToken ct = default) public async IAsyncEnumerable<StoredEvent> QueryAllReverseAsync(string? streamFilter = null, string? position = null, long take = long.MaxValue,
[EnumeratorCancellation] CancellationToken ct = default)
{ {
StreamEventsSlice currentSlice; if (take <= 0)
do
{ {
currentSlice = await connection.ReadStreamEventsForwardAsync(streamName, sliceStart, ReadPageSize, true); yield break;
}
if (currentSlice.Status == SliceReadStatus.Success) var streamName = await projectionClient.CreateProjectionAsync(streamFilter);
{
sliceStart = currentSlice.NextEventNumber;
foreach (var resolved in currentSlice.Events) var sliceStart = ProjectionClient.ParsePosition(position);
{
var storedEvent = Formatter.Read(resolved, prefix, serializer);
await callback(storedEvent); await foreach (var storedEvent in QueryAsync(streamName, sliceStart, take, ct))
} {
} yield return storedEvent;
} }
while (!currentSlice.IsEndOfStream && !ct.IsCancellationRequested);
} }
public async Task<IReadOnlyList<StoredEvent>> QueryLatestAsync(string streamName, int count) public async Task<IReadOnlyList<StoredEvent>> QueryLatestAsync(string streamName, int count)
@ -114,35 +118,12 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
var result = new List<StoredEvent>(); var result = new List<StoredEvent>();
var sliceStart = (long)StreamPosition.End; await foreach (var storedEvent in QueryReverseAsync(streamName, StreamPosition.End, default))
StreamEventsSlice currentSlice;
do
{
currentSlice = await connection.ReadStreamEventsBackwardAsync(GetStreamName(streamName), sliceStart, ReadPageSize, true);
if (currentSlice.Status == SliceReadStatus.Success)
{
sliceStart = currentSlice.NextEventNumber;
foreach (var resolved in currentSlice.Events)
{
var storedEvent = Formatter.Read(resolved, prefix, serializer);
result.Add(storedEvent);
}
}
}
while (!currentSlice.IsEndOfStream);
IEnumerable<StoredEvent> ordered = result.OrderBy(x => x.EventStreamNumber);
if (result.Count > count)
{ {
ordered = ordered.Skip(result.Count - count); result.Add(storedEvent);
} }
return ordered.ToList(); return result.ToList();
} }
} }
@ -154,29 +135,77 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
var result = new List<StoredEvent>(); var result = new List<StoredEvent>();
var sliceStart = streamPosition >= 0 ? streamPosition : StreamPosition.Start; await foreach (var storedEvent in QueryAsync(streamName, StreamPosition.End, default))
{
result.Add(storedEvent);
}
return result.ToList();
}
}
private async IAsyncEnumerable<StoredEvent> QueryAsync(string streamName, long sliceStart, long take = int.MaxValue,
[EnumeratorCancellation] CancellationToken ct = default)
{
var taken = take;
StreamEventsSlice currentSlice; StreamEventsSlice currentSlice;
do do
{
currentSlice = await connection.ReadStreamEventsForwardAsync(streamName, sliceStart, ReadPageSize, true);
if (currentSlice.Status == SliceReadStatus.Success)
{ {
currentSlice = await connection.ReadStreamEventsForwardAsync(GetStreamName(streamName), sliceStart, ReadPageSize, true); sliceStart = currentSlice.NextEventNumber;
if (currentSlice.Status == SliceReadStatus.Success) foreach (var resolved in currentSlice.Events)
{ {
sliceStart = currentSlice.NextEventNumber; var storedEvent = Formatter.Read(resolved, prefix, serializer);
foreach (var resolved in currentSlice.Events) yield return storedEvent;
{
var storedEvent = Formatter.Read(resolved, prefix, serializer);
result.Add(storedEvent); if (taken == take)
{
break;
} }
taken++;
} }
} }
while (!currentSlice.IsEndOfStream); }
while (!currentSlice.IsEndOfStream && !ct.IsCancellationRequested && taken < take);
}
private async IAsyncEnumerable<StoredEvent> QueryReverseAsync(string streamName, long sliceStart, long take = int.MaxValue,
[EnumeratorCancellation] CancellationToken ct = default)
{
var taken = take;
StreamEventsSlice currentSlice;
do
{
currentSlice = await connection.ReadStreamEventsBackwardAsync(streamName, sliceStart, ReadPageSize, true);
if (currentSlice.Status == SliceReadStatus.Success)
{
sliceStart = currentSlice.NextEventNumber;
return result; foreach (var resolved in currentSlice.Events.OrderByDescending(x => x.Event.EventNumber))
{
var storedEvent = Formatter.Read(resolved, prefix, serializer);
yield return storedEvent;
if (taken == take)
{
break;
}
taken++;
}
}
} }
while (!currentSlice.IsEndOfStream && !ct.IsCancellationRequested && taken < take);
} }
public Task DeleteStreamAsync(string streamName) public Task DeleteStreamAsync(string streamName)

40
backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/ProjectionClient.cs

@ -9,6 +9,7 @@ using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Net.Http;
using System.Net.Sockets; using System.Net.Sockets;
using System.Threading.Tasks; using System.Threading.Tasks;
using EventStore.ClientAPI; using EventStore.ClientAPI;
@ -23,21 +24,21 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
private readonly ConcurrentDictionary<string, bool> projections = new ConcurrentDictionary<string, bool>(); private readonly ConcurrentDictionary<string, bool> projections = new ConcurrentDictionary<string, bool>();
private readonly IEventStoreConnection connection; private readonly IEventStoreConnection connection;
private readonly string prefix; private readonly string projectionPrefix;
private readonly string projectionHost; private readonly string projectionHost;
private ProjectionsManager projectionsManager; private ProjectionsManager projectionsManager;
public ProjectionClient(IEventStoreConnection connection, string prefix, string projectionHost) public ProjectionClient(IEventStoreConnection connection, string projectionPrefix, string projectionHost)
{ {
this.connection = connection; this.connection = connection;
this.prefix = prefix; this.projectionPrefix = projectionPrefix;
this.projectionHost = projectionHost; this.projectionHost = projectionHost;
} }
private string CreateFilterProjectionName(string filter) private string CreateFilterProjectionName(string filter)
{ {
return $"by-{prefix.Slugify()}-{filter.Slugify()}"; return $"by-{projectionPrefix.Slugify()}-{filter.Slugify()}";
} }
public async Task<string> CreateProjectionAsync(string? streamFilter = null) public async Task<string> CreateProjectionAsync(string? streamFilter = null)
@ -50,7 +51,7 @@ namespace Squidex.Infrastructure.EventSourcing
$@"fromAll() $@"fromAll()
.when({{ .when({{
$any: function (s, e) {{ $any: function (s, e) {{
if (e.streamId.indexOf('{prefix}') === 0 && /{streamFilter}/.test(e.streamId.substring({prefix.Length + 1}))) {{ if (e.streamId.indexOf('{projectionPrefix}') === 0 && /{streamFilter}/.test(e.streamId.substring({projectionPrefix.Length + 1}))) {{
linkTo('{name}', e); linkTo('{name}', e);
}} }}
}} }}
@ -93,14 +94,33 @@ namespace Squidex.Infrastructure.EventSourcing
var endpoints = await Dns.GetHostAddressesAsync(addressParts[0]); var endpoints = await Dns.GetHostAddressesAsync(addressParts[0]);
var endpoint = new IPEndPoint(endpoints.First(x => x.AddressFamily == AddressFamily.InterNetwork), port); var endpoint = new IPEndPoint(endpoints.First(x => x.AddressFamily == AddressFamily.InterNetwork), port);
projectionsManager = async Task ConnectToSchemaAsync(string schema)
new ProjectionsManager(
connection.Settings.Log, endpoint,
connection.Settings.OperationTimeout);
try
{ {
projectionsManager =
new ProjectionsManager(
connection.Settings.Log, endpoint,
connection.Settings.OperationTimeout,
null,
schema);
await projectionsManager.ListAllAsync(connection.Settings.DefaultUserCredentials); await projectionsManager.ListAllAsync(connection.Settings.DefaultUserCredentials);
} }
try
{
try
{
await ConnectToSchemaAsync("https");
}
catch (HttpRequestException)
{
await ConnectToSchemaAsync("http");
}
catch (AggregateException ex) when (ex.Flatten().InnerException is HttpRequestException)
{
await ConnectToSchemaAsync("http");
}
}
catch (Exception ex) catch (Exception ex)
{ {
var error = new ConfigurationError($"GetEventStore cannot connect to event store projections: {projectionHost}."); var error = new ConfigurationError($"GetEventStore cannot connect to event store projections: {projectionHost}.");

4
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/Filtering.cs → backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/FilterExtensions.cs

@ -10,7 +10,7 @@ using MongoDB.Driver;
namespace Squidex.Infrastructure.EventSourcing namespace Squidex.Infrastructure.EventSourcing
{ {
internal static class Filtering internal static class FilterExtensions
{ {
public static FilterDefinition<MongoEventCommit> ByPosition(StreamPosition streamPosition) public static FilterDefinition<MongoEventCommit> ByPosition(StreamPosition streamPosition)
{ {
@ -81,7 +81,7 @@ namespace Squidex.Infrastructure.EventSourcing
} }
} }
public static IEnumerable<StoredEvent> Filtered(this MongoEventCommit commit, long streamPosition) public static IEnumerable<StoredEvent> Filtered(this MongoEventCommit commit, long streamPosition = EtagVersion.Empty)
{ {
var eventStreamOffset = commit.EventStreamOffset; var eventStreamOffset = commit.EventStreamOffset;

6
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs

@ -114,7 +114,7 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
using (var combined = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, stopToken.Token)) using (var combined = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, stopToken.Token))
{ {
await eventStore.QueryAsync(async storedEvent => await foreach (var storedEvent in eventStore.QueryAllAsync(streamFilter, position, ct: combined.Token))
{ {
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
@ -130,7 +130,7 @@ namespace Squidex.Infrastructure.EventSourcing
lastRawPosition = storedEvent.EventPosition; lastRawPosition = storedEvent.EventPosition;
} }
}, streamFilter, position, combined.Token); }
} }
} }
@ -141,7 +141,7 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
var result = new EmptyPipelineDefinition<ChangeStreamDocument<MongoEventCommit>>(); var result = new EmptyPipelineDefinition<ChangeStreamDocument<MongoEventCommit>>();
var byStream = Filtering.ByChangeInStream(streamFilter); var byStream = FilterExtensions.ByChangeInStream(streamFilter);
if (byStream != null) if (byStream != null)
{ {

115
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs

@ -8,6 +8,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MongoDB.Driver; using MongoDB.Driver;
@ -23,17 +24,6 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
private static readonly List<StoredEvent> EmptyEvents = new List<StoredEvent>(); private static readonly List<StoredEvent> EmptyEvents = new List<StoredEvent>();
public Task CreateIndexAsync(string property)
{
Guard.NotNullOrEmpty(property, nameof(property));
return Collection.Indexes.CreateOneAsync(
new CreateIndexModel<MongoEventCommit>(
Index
.Ascending(CreateIndexPath(property))
.Ascending(TimestampField)));
}
public IEventSubscription CreateSubscription(IEventSubscriber subscriber, string? streamFilter = null, string? position = null) public IEventSubscription CreateSubscription(IEventSubscriber subscriber, string? streamFilter = null, string? position = null)
{ {
Guard.NotNull(subscriber, nameof(subscriber)); Guard.NotNull(subscriber, nameof(subscriber));
@ -64,21 +54,9 @@ namespace Squidex.Infrastructure.EventSourcing
Filter.Eq(EventStreamField, streamName)) Filter.Eq(EventStreamField, streamName))
.Sort(Sort.Descending(TimestampField)).Limit(count).ToListAsync(); .Sort(Sort.Descending(TimestampField)).Limit(count).ToListAsync();
var result = new List<StoredEvent>(); var result = commits.Select(x => x.Filtered()).Reverse().SelectMany(x => x).TakeLast(count).ToList();
foreach (var commit in commits) return result;
{
result.AddRange(commit.Filtered(long.MinValue));
}
IEnumerable<StoredEvent> ordered = result.OrderBy(x => x.EventStreamNumber);
if (result.Count > count)
{
ordered = ordered.Skip(result.Count - count);
}
return ordered.ToList();
} }
} }
@ -125,35 +103,95 @@ namespace Squidex.Infrastructure.EventSourcing
} }
} }
public Task QueryAsync(Func<StoredEvent, Task> callback, string? streamFilter = null, string? position = null, CancellationToken ct = default) public async IAsyncEnumerable<StoredEvent> QueryAllReverseAsync(string? streamFilter = null, string? position = null, long take = long.MaxValue,
[EnumeratorCancellation] CancellationToken ct = default)
{ {
Guard.NotNull(callback, nameof(callback)); if (take <= 0)
{
yield break;
}
StreamPosition lastPosition = position; StreamPosition lastPosition = position;
var filterDefinition = CreateFilter(streamFilter, lastPosition); var filterDefinition = CreateFilter(streamFilter, lastPosition);
return QueryAsync(callback, lastPosition, filterDefinition, ct); var find =
Collection.Find(filterDefinition, options: Batching.Options)
.Limit((int)take).Sort(Sort.Descending(TimestampField));
var taken = 0;
using (var cursor = await find.ToCursorAsync(ct))
{
while (taken < take && await cursor.MoveNextAsync(ct))
{
foreach (var current in cursor.Current)
{
foreach (var @event in current.Filtered(position).Reverse())
{
yield return @event;
taken++;
if (taken == take)
{
break;
}
}
if (taken == take)
{
break;
}
}
}
}
} }
private async Task QueryAsync(Func<StoredEvent, Task> callback, StreamPosition position, EventFilter filter, CancellationToken ct = default) public async IAsyncEnumerable<StoredEvent> QueryAllAsync(string? streamFilter = null, string? position = null, long take = long.MaxValue,
[EnumeratorCancellation] CancellationToken ct = default)
{ {
using (Profiler.TraceMethod<MongoEventStore>()) StreamPosition lastPosition = position;
var filterDefinition = CreateFilter(streamFilter, lastPosition);
var find =
Collection.Find(filterDefinition)
.Limit((int)take).Sort(Sort.Ascending(TimestampField));
var taken = 0;
using (var cursor = await find.ToCursorAsync(ct))
{ {
await Collection.Find(filter, options: Batching.Options).Sort(Sort.Ascending(TimestampField)).ForEachPipedAsync(async commit => while (taken < take && await cursor.MoveNextAsync(ct))
{ {
foreach (var @event in commit.Filtered(position)) foreach (var current in cursor.Current)
{ {
await callback(@event); foreach (var @event in current.Filtered(position))
{
yield return @event;
taken++;
if (taken == take)
{
break;
}
}
if (taken == take)
{
break;
}
} }
}, ct); }
} }
} }
private static EventFilter CreateFilter(string? streamFilter, StreamPosition streamPosition) private static EventFilter CreateFilter(string? streamFilter, StreamPosition streamPosition)
{ {
var byPosition = Filtering.ByPosition(streamPosition); var byPosition = FilterExtensions.ByPosition(streamPosition);
var byStream = Filtering.ByStream(streamFilter); var byStream = FilterExtensions.ByStream(streamFilter);
if (byStream != null) if (byStream != null)
{ {
@ -162,10 +200,5 @@ namespace Squidex.Infrastructure.EventSourcing
return byPosition; return byPosition;
} }
private static string CreateIndexPath(string property)
{
return $"Events.Metadata.{property}";
}
} }
} }

36
backend/src/Squidex.Infrastructure/CollectionExtensions.cs

@ -8,14 +8,16 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously
namespace Squidex.Infrastructure namespace Squidex.Infrastructure
{ {
public static class CollectionExtensions public static class CollectionExtensions
{ {
public static bool SetEquals<T>(this IReadOnlyCollection<T> source, IReadOnlyCollection<T> other)
{
return source.Count == other.Count && source.Intersect(other).Count() == other.Count;
}
public static IEnumerable<IEnumerable<TSource>> Batch<TSource>(this IEnumerable<TSource> source, int size) public static IEnumerable<IEnumerable<TSource>> Batch<TSource>(this IEnumerable<TSource> source, int size)
{ {
TSource[]? bucket = null; TSource[]? bucket = null;
@ -48,34 +50,14 @@ namespace Squidex.Infrastructure
} }
} }
public static async Task<List<T>> ToListAsync<T>(this IAsyncEnumerable<T> source) public static bool SetEquals<T>(this IReadOnlyCollection<T> source, IReadOnlyCollection<T> other, IEqualityComparer<T> comparer)
{
var result = new List<T>();
await foreach (var item in source)
{
result.Add(item);
}
return result;
}
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IEnumerable<T> source)
{
foreach (var item in source)
{
yield return item;
}
}
public static bool SetEquals<T>(this IReadOnlyCollection<T> source, IReadOnlyCollection<T> other)
{ {
return source.Count == other.Count && source.Intersect(other).Count() == other.Count; return source.Count == other.Count && source.Intersect(other, comparer).Count() == other.Count;
} }
public static bool SetEquals<T>(this IReadOnlyCollection<T> source, IReadOnlyCollection<T> other, IEqualityComparer<T> comparer) public static IEnumerable<T> Reverse<T>(this IEnumerable<T> source, bool reverse)
{ {
return source.Count == other.Count && source.Intersect(other, comparer).Count() == other.Count; return reverse ? source.Reverse() : source;
} }
public static IResultList<T> SortSet<T, TKey>(this IResultList<T> input, Func<T, TKey> idProvider, IReadOnlyList<TKey> ids) where T : class public static IResultList<T> SortSet<T, TKey>(this IResultList<T> input, Func<T, TKey> idProvider, IReadOnlyList<TKey> ids) where T : class

4
backend/src/Squidex.Infrastructure/Commands/Rebuilder.cs

@ -59,12 +59,12 @@ namespace Squidex.Infrastructure.Commands
await InsertManyAsync<T, TState>(store, async target => await InsertManyAsync<T, TState>(store, async target =>
{ {
await eventStore.QueryAsync(async storedEvent => await foreach (var storedEvent in eventStore.QueryAllAsync(filter, ct: ct))
{ {
var id = storedEvent.Data.Headers.AggregateId(); var id = storedEvent.Data.Headers.AggregateId();
await target(id); await target(id);
}, filter, ct: ct); }
}, ct); }, ct);
} }

24
backend/src/Squidex.Infrastructure/EventSourcing/EventData.cs

@ -5,27 +5,9 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Infrastructure.EventSourcing namespace Squidex.Infrastructure.EventSourcing
{ {
public sealed class EventData public sealed record EventData(string Type, EnvelopeHeaders Headers, string Payload);
{
public EnvelopeHeaders Headers { get; }
public string Payload { get; }
public string Type { get; set; }
public EventData(string type, EnvelopeHeaders headers, string payload)
{
Guard.NotNull(type, nameof(type));
Guard.NotNull(headers, nameof(headers));
Guard.NotNull(payload, nameof(payload));
Headers = headers;
Payload = payload;
Type = type;
}
}
} }

8
backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs

@ -14,11 +14,13 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
public interface IEventStore public interface IEventStore
{ {
Task<IReadOnlyList<StoredEvent>> QueryLatestAsync(string streamName, int count); Task<IReadOnlyList<StoredEvent>> QueryLatestAsync(string streamName, int take = int.MaxValue);
Task<IReadOnlyList<StoredEvent>> QueryAsync(string streamName, long streamPosition = 0); Task<IReadOnlyList<StoredEvent>> QueryAsync(string streamName, long streamPosition = 0);
Task QueryAsync(Func<StoredEvent, Task> callback, string? streamFilter = null, string? position = null, CancellationToken ct = default); IAsyncEnumerable<StoredEvent> QueryAllReverseAsync(string? streamFilter = null, string? position = null, long take = long.MaxValue, CancellationToken ct = default);
IAsyncEnumerable<StoredEvent> QueryAllAsync(string? streamFilter = null, string? position = null, long take = long.MaxValue, CancellationToken ct = default);
Task AppendAsync(Guid commitId, string streamName, ICollection<EventData> events); Task AppendAsync(Guid commitId, string streamName, ICollection<EventData> events);
@ -42,7 +44,7 @@ namespace Squidex.Infrastructure.EventSourcing
foreach (var streamName in streamNames) foreach (var streamName in streamNames)
{ {
result[streamName] = await QueryAsync(streamName); result[streamName] = await QueryAsync(streamName, 0);
} }
return result; return result;

4
backend/src/Squidex.Infrastructure/EventSourcing/PollingSubscription.cs

@ -28,12 +28,12 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
try try
{ {
await eventStore.QueryAsync(async storedEvent => await foreach (var storedEvent in eventStore.QueryAllAsync(streamFilter, position, ct: ct))
{ {
await eventSubscriber.OnEventAsync(this, storedEvent); await eventSubscriber.OnEventAsync(this, storedEvent);
position = storedEvent.EventPosition; position = storedEvent.EventPosition;
}, streamFilter, position, ct); }
} }
catch (Exception ex) catch (Exception ex)
{ {

25
backend/src/Squidex.Infrastructure/EventSourcing/StoredEvent.cs

@ -5,30 +5,11 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Infrastructure.EventSourcing namespace Squidex.Infrastructure.EventSourcing
{ {
public sealed class StoredEvent public sealed record StoredEvent(string StreamName, string EventPosition, long EventStreamNumber, EventData Data)
{ {
public string StreamName { get; }
public string EventPosition { get; }
public long EventStreamNumber { get; }
public EventData Data { get; }
public StoredEvent(string streamName, string eventPosition, long eventStreamNumber, EventData data)
{
Guard.NotNullOrEmpty(streamName, nameof(streamName));
Guard.NotNullOrEmpty(eventPosition, nameof(eventPosition));
Guard.NotNull(data, nameof(data));
Data = data;
EventPosition = eventPosition;
EventStreamNumber = eventStreamNumber;
StreamName = streamName;
}
} }
} }

2
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -32,7 +32,7 @@
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="5.0.0" /> <PackageReference Include="System.Collections.Immutable" Version="5.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" /> <PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="System.Linq" Version="4.3.0" /> <PackageReference Include="System.Linq.Async" Version="5.0.0" />
<PackageReference Include="System.Reactive" Version="5.0.0" /> <PackageReference Include="System.Reactive" Version="5.0.0" />
<PackageReference Include="System.Reflection.TypeExtensions" Version="4.7.0" /> <PackageReference Include="System.Reflection.TypeExtensions" Version="4.7.0" />
<PackageReference Include="System.Security.Claims" Version="4.3.0" /> <PackageReference Include="System.Security.Claims" Version="4.3.0" />

2
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs

@ -53,7 +53,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Converters
public RuleTriggerDto Visit(ContentChangedTriggerV2 trigger) public RuleTriggerDto Visit(ContentChangedTriggerV2 trigger)
{ {
var schemas = trigger.Schemas.Select(x => SimpleMapper.Map(x, new ContentChangedRuleTriggerSchemaDto())).ToArray(); var schemas = trigger.Schemas?.Select(ContentChangedRuleTriggerSchemaDto.FromTrigger).ToArray();
return new ContentChangedRuleTriggerDto { Schemas = schemas, HandleAll = trigger.HandleAll }; return new ContentChangedRuleTriggerDto { Schemas = schemas, HandleAll = trigger.HandleAll };
} }

49
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventDto.cs

@ -0,0 +1,49 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Entities.Rules.Runner;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Rules.Models
{
public sealed record SimulatedRuleEventDto
{
/// <summary>
/// The name of the event.
/// </summary>
[Required]
public string EventName { get; set; }
/// <summary>
/// The data for the action.
/// </summary>
public string? ActionName { get; set; }
/// <summary>
/// The name of the action.
/// </summary>
public string? ActionData { get; set; }
/// <summary>
/// The name of the event.
/// </summary>
public string? Error { get; set; }
/// <summary>
/// The reason why the event has been skipped.
/// </summary>
[Required]
public SkipReason SkipReason { get; set; }
public static SimulatedRuleEventDto FromSimulatedRuleEvent(SimulatedRuleEvent ruleEvent)
{
return SimpleMapper.Map(ruleEvent, new SimulatedRuleEventDto());
}
}
}

42
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/SimulatedRuleEventsDto.cs

@ -0,0 +1,42 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.Runner;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Validation;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Rules.Models
{
public sealed class SimulatedRuleEventsDto : Resource
{
/// <summary>
/// The simulated rule events.
/// </summary>
[LocalizedRequired]
public SimulatedRuleEventDto[] Items { get; set; }
/// <summary>
/// The total number of simulated rule events.
/// </summary>
public long Total { get; set; }
public static SimulatedRuleEventsDto FromSimulatedRuleEvents(IList<SimulatedRuleEvent> events)
{
var result = new SimulatedRuleEventsDto
{
Total = events.Count,
Items = events.Select(SimulatedRuleEventDto.FromSimulatedRuleEvent).ToArray()
};
return result;
}
}
}

7
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerDto.cs

@ -9,8 +9,6 @@ using System.Linq;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Infrastructure.Collections; using Squidex.Infrastructure.Collections;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers
{ {
@ -19,8 +17,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers
/// <summary> /// <summary>
/// The schema settings. /// The schema settings.
/// </summary> /// </summary>
[LocalizedRequired] public ContentChangedRuleTriggerSchemaDto[]? Schemas { get; set; }
public ContentChangedRuleTriggerSchemaDto[] Schemas { get; set; }
/// <summary> /// <summary>
/// Determines whether the trigger should handle all content changes events. /// Determines whether the trigger should handle all content changes events.
@ -29,7 +26,7 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers
public override RuleTrigger ToTrigger() public override RuleTrigger ToTrigger()
{ {
var schemas = Schemas.Select(x => SimpleMapper.Map(x, new ContentChangedTriggerSchemaV2())).ToReadOnlyCollection(); var schemas = Schemas?.Select(x => x.ToTrigger()).ToReadOnlyCollection();
return new ContentChangedTriggerV2 { HandleAll = HandleAll, Schemas = schemas }; return new ContentChangedTriggerV2 { HandleAll = HandleAll, Schemas = schemas };
} }

12
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedRuleTriggerSchemaDto.cs

@ -5,7 +5,9 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers
{ {
@ -20,5 +22,15 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers
/// Javascript condition when to trigger. /// Javascript condition when to trigger.
/// </summary> /// </summary>
public string? Condition { get; set; } public string? Condition { get; set; }
public ContentChangedTriggerSchemaV2 ToTrigger()
{
return SimpleMapper.Map(this, new ContentChangedTriggerSchemaV2());
}
public static ContentChangedRuleTriggerSchemaDto FromTrigger(ContentChangedTriggerSchemaV2 trigger)
{
return SimpleMapper.Map(trigger, new ContentChangedRuleTriggerSchemaDto());
}
} }
} }

38
backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs

@ -15,6 +15,7 @@ using Microsoft.Net.Http.Headers;
using NodaTime; using NodaTime;
using Squidex.Areas.Api.Controllers.Rules.Models; using Squidex.Areas.Api.Controllers.Rules.Models;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.Commands; using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Domain.Apps.Entities.Rules.Repositories; using Squidex.Domain.Apps.Entities.Rules.Repositories;
@ -32,13 +33,15 @@ namespace Squidex.Areas.Api.Controllers.Rules
[ApiExplorerSettings(GroupName = nameof(Rules))] [ApiExplorerSettings(GroupName = nameof(Rules))]
public sealed class RulesController : ApiController public sealed class RulesController : ApiController
{ {
private readonly EventJsonSchemaGenerator eventJsonSchemaGenerator;
private readonly IAppProvider appProvider;
private readonly IRuleEventRepository ruleEventsRepository;
private readonly IRuleQueryService ruleQuery; private readonly IRuleQueryService ruleQuery;
private readonly IRuleRunnerService ruleRunnerService; private readonly IRuleRunnerService ruleRunnerService;
private readonly IRuleEventRepository ruleEventsRepository;
private readonly EventJsonSchemaGenerator eventJsonSchemaGenerator;
private readonly RuleRegistry ruleRegistry; private readonly RuleRegistry ruleRegistry;
public RulesController(ICommandBus commandBus, public RulesController(ICommandBus commandBus,
IAppProvider appProvider,
IRuleEventRepository ruleEventsRepository, IRuleEventRepository ruleEventsRepository,
IRuleQueryService ruleQuery, IRuleQueryService ruleQuery,
IRuleRunnerService ruleRunnerService, IRuleRunnerService ruleRunnerService,
@ -46,6 +49,7 @@ namespace Squidex.Areas.Api.Controllers.Rules
EventJsonSchemaGenerator eventJsonSchemaGenerator) EventJsonSchemaGenerator eventJsonSchemaGenerator)
: base(commandBus) : base(commandBus)
{ {
this.appProvider = appProvider;
this.ruleEventsRepository = ruleEventsRepository; this.ruleEventsRepository = ruleEventsRepository;
this.ruleQuery = ruleQuery; this.ruleQuery = ruleQuery;
this.ruleRunnerService = ruleRunnerService; this.ruleRunnerService = ruleRunnerService;
@ -260,6 +264,36 @@ namespace Squidex.Areas.Api.Controllers.Rules
return NoContent(); return NoContent();
} }
/// <summary>
/// Simulate a rule.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The id of the rule to simulate.</param>
/// <returns>
/// 200 => Rule simulated.
/// 404 => Rule or app not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/rules/{id}/simulate/")]
[ProducesResponseType(typeof(SimulatedRuleEventsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(Permissions.AppRulesEvents)]
[ApiCosts(5)]
public async Task<IActionResult> Simulate(string app, DomainId id)
{
var rule = await appProvider.GetRuleAsync(AppId, id);
if (rule == null)
{
return NotFound();
}
var simulation = await ruleRunnerService.SimulateAsync(rule, HttpContext.RequestAborted);
var response = SimulatedRuleEventsDto.FromSimulatedRuleEvents(simulation);
return Ok(response);
}
/// <summary> /// <summary>
/// Delete a rule. /// Delete a rule.
/// </summary> /// </summary>

1
backend/src/Squidex/Areas/Api/Controllers/UI/MyUIOptions.cs

@ -6,7 +6,6 @@
// ========================================================================== // ==========================================================================
using System.Collections.Generic; using System.Collections.Generic;
using Squidex.Domain.Apps.Core.Apps;
namespace Squidex.Areas.Api.Controllers.UI namespace Squidex.Areas.Api.Controllers.UI
{ {

44
backend/src/Squidex/Config/Domain/EventSourcingServices.cs

@ -5,20 +5,14 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Linq; using System.Linq;
using EventStore.ClientAPI;
using Microsoft.Azure.Documents.Client;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver; using MongoDB.Driver;
using Newtonsoft.Json;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Diagnostics;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.EventSourcing.Grains; using Squidex.Infrastructure.EventSourcing.Grains;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
namespace Squidex.Config.Domain namespace Squidex.Config.Domain
@ -42,44 +36,6 @@ namespace Squidex.Config.Domain
return new MongoEventStore(mongDatabase, c.GetRequiredService<IEventNotifier>()); return new MongoEventStore(mongDatabase, c.GetRequiredService<IEventNotifier>());
}) })
.As<IEventStore>(); .As<IEventStore>();
},
["CosmosDb"] = () =>
{
var cosmosDbConfiguration = config.GetRequiredValue("eventStore:cosmosDB:configuration");
var cosmosDbMasterKey = config.GetRequiredValue("eventStore:cosmosDB:masterKey");
var cosmosDbDatabase = config.GetRequiredValue("eventStore:cosmosDB:database");
services.AddSingletonAs(c => new DocumentClient(new Uri(cosmosDbConfiguration), cosmosDbMasterKey, c.GetRequiredService<JsonSerializerSettings>()))
.AsSelf();
services.AddSingletonAs(c => new CosmosDbEventStore(
c.GetRequiredService<DocumentClient>(),
cosmosDbMasterKey,
cosmosDbDatabase,
c.GetRequiredService<IJsonSerializer>()))
.As<IEventStore>();
services.AddHealthChecks()
.AddCheck<CosmosDbHealthCheck>("CosmosDB", tags: new[] { "node" });
},
["GetEventStore"] = () =>
{
var eventStoreConfiguration = config.GetRequiredValue("eventStore:getEventStore:configuration");
var eventStoreProjectionHost = config.GetRequiredValue("eventStore:getEventStore:projectionHost");
var eventStorePrefix = config.GetValue<string>("eventStore:getEventStore:prefix");
services.AddSingletonAs(_ => EventStoreConnection.Create(eventStoreConfiguration))
.As<IEventStoreConnection>();
services.AddSingletonAs(c => new GetEventStore(
c.GetRequiredService<IEventStoreConnection>(),
c.GetRequiredService<IJsonSerializer>(),
eventStorePrefix,
eventStoreProjectionHost))
.As<IEventStore>();
services.AddHealthChecks()
.AddCheck<GetEventStoreHealthCheck>("EventStore", tags: new[] { "node" });
} }
}); });

2
backend/src/Squidex/Config/Domain/RuleServices.cs

@ -72,7 +72,7 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<RuleQueryService>() services.AddSingletonAs<RuleQueryService>()
.As<IRuleQueryService>(); .As<IRuleQueryService>();
services.AddSingletonAs<GrainRuleRunnerService>() services.AddSingletonAs<DefaultRuleRunnerService>()
.As<IRuleRunnerService>(); .As<IRuleRunnerService>();
services.AddSingletonAs<RuleEnricher>() services.AddSingletonAs<RuleEnricher>()

3
backend/src/Squidex/Squidex.csproj

@ -24,8 +24,6 @@
<ProjectReference Include="..\Squidex.Domain.Apps.Events\Squidex.Domain.Apps.Events.csproj" /> <ProjectReference Include="..\Squidex.Domain.Apps.Events\Squidex.Domain.Apps.Events.csproj" />
<ProjectReference Include="..\Squidex.Domain.Users\Squidex.Domain.Users.csproj" /> <ProjectReference Include="..\Squidex.Domain.Users\Squidex.Domain.Users.csproj" />
<ProjectReference Include="..\Squidex.Domain.Users.MongoDb\Squidex.Domain.Users.MongoDb.csproj" /> <ProjectReference Include="..\Squidex.Domain.Users.MongoDb\Squidex.Domain.Users.MongoDb.csproj" />
<ProjectReference Include="..\Squidex.Infrastructure.Azure\Squidex.Infrastructure.Azure.csproj" />
<ProjectReference Include="..\Squidex.Infrastructure.GetEventStore\Squidex.Infrastructure.GetEventStore.csproj" />
<ProjectReference Include="..\Squidex.Infrastructure.RabbitMq\Squidex.Infrastructure.RabbitMq.csproj" /> <ProjectReference Include="..\Squidex.Infrastructure.RabbitMq\Squidex.Infrastructure.RabbitMq.csproj" />
<ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" /> <ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
<ProjectReference Include="..\Squidex.Infrastructure.MongoDb\Squidex.Infrastructure.MongoDb.csproj" /> <ProjectReference Include="..\Squidex.Infrastructure.MongoDb\Squidex.Infrastructure.MongoDb.csproj" />
@ -47,6 +45,7 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.5" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="5.0.5" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="5.0.5" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="5.0.0" /> <PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="5.0.0" />
<PackageReference Include="Microsoft.CodeAnalysis.RulesetToEditorconfigConverter" Version="3.0.0" />
<PackageReference Include="Microsoft.Data.Edm" Version="5.8.4" /> <PackageReference Include="Microsoft.Data.Edm" Version="5.8.4" />
<PackageReference Include="Microsoft.OData.Core" Version="7.8.3" /> <PackageReference Include="Microsoft.OData.Core" Version="7.8.3" />
<PackageReference Include="Microsoft.Orleans.Core" Version="3.4.2" /> <PackageReference Include="Microsoft.Orleans.Core" Version="3.4.2" />

36
backend/src/Squidex/appsettings.json

@ -494,7 +494,7 @@
/* /*
* Define the type of the event store. * Define the type of the event store.
* *
* Supported: MongoDb, GetEventStore, CosmosDb * Supported: MongoDb
*/ */
"type": "MongoDb", "type": "MongoDb",
"mongoDb": { "mongoDb": {
@ -505,40 +505,6 @@
*/ */
"configuration": "mongodb://localhost", "configuration": "mongodb://localhost",
/*
* The name of the event store database.
*/
"database": "Squidex"
},
"getEventStore": {
/*
* The connection string to your EventStore.
*
* Read Mode: http://docs.geteventstore.com/dotnet-api/4.0.0/connecting-to-a-server/
*/
"configuration": "ConnectTo=tcp://admin:changeit@localhost:1113; HeartBeatTimeout=500; MaxReconnections=-1",
/*
* The host name of your EventStore where projection requests will be sent to.
*/
"projectionHost": "localhost",
/*
* Prefix for all streams and projections (for multiple installations).
*/
"prefix": "squidex"
},
"cosmosDb": {
/*
* The connection string to your CosmosDB instance.
*/
"configuration": "https://localhost:8081",
/*
* The primary access key.
*/
"masterKey": "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==",
/* /*
* The name of the event store database. * The name of the event store database.
*/ */

391
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs

@ -93,6 +93,57 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
eventEnricher, TestUtils.DefaultSerializer, clock, log, typeNameRegistry); eventEnricher, TestUtils.DefaultSerializer, clock, log, typeNameRegistry);
} }
[Fact]
public void Should_calculate_event_name_from_trigger_handler()
{
var @event = new ContentCreated();
A.CallTo(() => ruleTriggerHandler.Handles(@event))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.GetName(@event))
.Returns("custom-name");
var name = sut.GetName(@event);
Assert.Equal("custom-name", name);
}
[Fact]
public void Should_calculate_default_name_if_trigger_handler_returns_no_name()
{
var @event = new ContentCreated();
A.CallTo(() => ruleTriggerHandler.Handles(@event))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.GetName(@event))
.Returns(null);
var name = sut.GetName(@event);
Assert.Equal("ContentCreated", name);
A.CallTo(() => ruleTriggerHandler.GetName(@event))
.MustHaveHappened();
}
[Fact]
public void Should_calculate_default_name_if_trigger_handler_cannot_not_handle_event()
{
var @event = new ContentCreated();
A.CallTo(() => ruleTriggerHandler.Handles(@event))
.Returns(false);
var name = sut.GetName(@event);
Assert.Equal("ContentCreated", name);
A.CallTo(() => ruleTriggerHandler.GetName(@event))
.MustNotHaveHappened();
}
[Fact] [Fact]
public void Should_not_run_from_snapshots_if_no_trigger_handler_registered() public void Should_not_run_from_snapshots_if_no_trigger_handler_registered()
{ {
@ -107,7 +158,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(false); .Returns(false);
var result = sut.CanCreateSnapshotEvents(ValidRule()); var result = sut.CanCreateSnapshotEvents(Rule());
Assert.False(result); Assert.False(result);
} }
@ -118,7 +169,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(true); .Returns(true);
var result = sut.CanCreateSnapshotEvents(ValidRule()); var result = sut.CanCreateSnapshotEvents(Rule());
Assert.True(result); Assert.True(result);
} }
@ -129,11 +180,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(false); .Returns(false);
var jobs = await sut.CreateSnapshotJobsAsync(ValidRule(), ruleId, appId.Id).ToListAsync(); var jobs = await sut.CreateSnapshotJobsAsync(Rule()).ToListAsync();
Assert.Empty(jobs); Assert.Empty(jobs);
A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(A<RuleTrigger>._, A<DomainId>._)) A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(A<RuleContext>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -143,11 +194,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(true); .Returns(true);
var jobs = await sut.CreateSnapshotJobsAsync(ValidRule().Disable(), ruleId, appId.Id).ToListAsync(); var jobs = await sut.CreateSnapshotJobsAsync(Rule(disable: true)).ToListAsync();
Assert.Empty(jobs); Assert.Empty(jobs);
A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(A<RuleTrigger>._, A<DomainId>._)) A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(A<RuleContext>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -157,11 +208,11 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(true); .Returns(true);
var jobs = await sut.CreateSnapshotJobsAsync(RuleInvalidTrigger(), ruleId, appId.Id).ToListAsync(); var jobs = await sut.CreateSnapshotJobsAsync(RuleInvalidTrigger()).ToListAsync();
Assert.Empty(jobs); Assert.Empty(jobs);
A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(A<RuleTrigger>._, A<DomainId>._)) A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(A<RuleContext>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -171,33 +222,33 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(true); .Returns(true);
var jobs = await sut.CreateSnapshotJobsAsync(RuleInvalidAction(), ruleId, appId.Id).ToListAsync(); var jobs = await sut.CreateSnapshotJobsAsync(RuleInvalidAction()).ToListAsync();
Assert.Empty(jobs); Assert.Empty(jobs);
A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(A<RuleTrigger>._, A<DomainId>._)) A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(A<RuleContext>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_create_jobs_from_snapshots() public async Task Should_create_jobs_from_snapshots()
{ {
var rule = ValidRule(); var context = Rule();
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(true); .Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(A<EnrichedEvent>._, rule.Trigger)) A.CallTo(() => ruleTriggerHandler.Trigger(A<EnrichedEvent>._, context))
.Returns(true); .Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(rule.Trigger, appId.Id)) A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(context, default))
.Returns(new List<EnrichedEvent> .Returns(new List<EnrichedEvent>
{ {
new EnrichedContentEvent { AppId = appId }, new EnrichedContentEvent { AppId = appId },
new EnrichedContentEvent { AppId = appId } new EnrichedContentEvent { AppId = appId }
}.ToAsyncEnumerable()); }.ToAsyncEnumerable());
var result = await sut.CreateSnapshotJobsAsync(rule, ruleId, appId.Id).ToListAsync(); var result = await sut.CreateSnapshotJobsAsync(context).ToListAsync();
Assert.Equal(2, result.Count(x => x.Job != null && x.Exception == null)); Assert.Equal(2, result.Count(x => x.Job != null && x.Exception == null));
} }
@ -205,179 +256,265 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[Fact] [Fact]
public async Task Should_create_jobs_with_exceptions_from_snapshots() public async Task Should_create_jobs_with_exceptions_from_snapshots()
{ {
var rule = ValidRule(); var context = Rule();
A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents) A.CallTo(() => ruleTriggerHandler.CanCreateSnapshotEvents)
.Returns(true); .Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(A<EnrichedEvent>._, rule.Trigger)) A.CallTo(() => ruleTriggerHandler.Trigger(A<EnrichedEvent>._, context))
.Throws(new InvalidOperationException()); .Throws(new InvalidOperationException());
A.CallTo(() => ruleTriggerHandler.CreateSnapshotEvents(rule.Trigger, appId.Id)) A.CallTo(() => ruleTriggerHandler.CreateSnapshotEventsAsync(context, default))
.Returns(new List<EnrichedEvent> .Returns(new List<EnrichedEvent>
{ {
new EnrichedContentEvent { AppId = appId }, new EnrichedContentEvent { AppId = appId },
new EnrichedContentEvent { AppId = appId } new EnrichedContentEvent { AppId = appId }
}.ToAsyncEnumerable()); }.ToAsyncEnumerable());
var result = await sut.CreateSnapshotJobsAsync(rule, ruleId, appId.Id).ToListAsync(); var result = await sut.CreateSnapshotJobsAsync(context).ToListAsync();
Assert.Equal(2, result.Count(x => x.Job == null && x.Exception != null)); Assert.Equal(2, result.Count(x => x.Job == null && x.Exception != null));
} }
[Fact] [Fact]
public async Task Should_not_create_job_if_rule_disabled() public async Task Should_create_debug_rob_if_rule_disabled()
{ {
var @event = Envelope.Create(new ContentCreated()); var @event = Envelope.Create(new ContentCreated());
var jobs = await sut.CreateJobsAsync(ValidRule().Disable(), ruleId, @event); var (_, _, reason) = await sut.CreateJobsAsync(@event, Rule(disable: true)).SingleAsync();
Assert.Empty(jobs); Assert.Equal(SkipReason.Disabled, reason);
A.CallTo(() => ruleTriggerHandler.Trigger(A<AppEvent>._, A<RuleTrigger>._, ruleId)) A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, A<RuleContext>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_not_create_job_for_invalid_event() public async Task Should_create_debug_job_for_invalid_event()
{ {
var @event = Envelope.Create(new InvalidEvent()); var @event = Envelope.Create(new InvalidEvent());
var jobs = await sut.CreateJobsAsync(ValidRule(), ruleId, @event); var (_, _, reason) = await sut.CreateJobsAsync(@event, Rule()).SingleAsync();
Assert.Empty(jobs); Assert.Equal(SkipReason.EventMismatch, reason);
A.CallTo(() => ruleTriggerHandler.Trigger(A<AppEvent>._, A<RuleTrigger>._, ruleId)) A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, A<RuleContext>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_not_create_job_if_no_trigger_handler_registered() public async Task Should_create_debug_job_if_no_trigger_handler_registered()
{ {
var @event = Envelope.Create(new ContentCreated()); var @event = Envelope.Create(new ContentCreated());
var jobs = await sut.CreateJobsAsync(RuleInvalidTrigger(), ruleId, @event); var (_, _, reason) = await sut.CreateJobsAsync(@event, RuleInvalidTrigger()).SingleAsync();
Assert.Empty(jobs); Assert.Equal(SkipReason.NoTrigger, reason);
A.CallTo(() => ruleTriggerHandler.Trigger(A<AppEvent>._, A<RuleTrigger>._, ruleId)) A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, A<RuleContext>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_not_create_job_if_no_action_handler_registered() public async Task Should_create_debug_job_if_trigger_handler_does_not_handle_event()
{ {
var @event = Envelope.Create(new ContentCreated()); var @event = Envelope.Create(new ContentCreated());
var jobs = await sut.CreateJobsAsync(RuleInvalidAction(), ruleId, @event); var (_, _, reason) = await sut.CreateJobsAsync(@event, Rule()).SingleAsync();
Assert.Empty(jobs); Assert.Equal(SkipReason.WrongEventForTrigger, reason);
A.CallTo(() => ruleTriggerHandler.Trigger(A<AppEvent>._, A<RuleTrigger>._, ruleId)) A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, A<RuleContext>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_not_create_job_if_too_old() public async Task Should_create_debug_job_if_no_action_handler_registered()
{ {
var @event = Envelope.Create(new ContentCreated()).SetTimestamp(clock.GetCurrentInstant().Minus(Duration.FromDays(3))); var @event = Envelope.Create(new ContentCreated());
var jobs = await sut.CreateJobsAsync(ValidRule(), ruleId, @event, true); A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true);
Assert.Empty(jobs); var (_, _, reason) = await sut.CreateJobsAsync(@event, RuleInvalidAction()).SingleAsync();
Assert.Equal(SkipReason.NoAction, reason);
A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, A<RuleContext>._))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_create_debug_job_if_too_old()
{
var @event =
Envelope.Create(new ContentCreated())
.SetTimestamp(clock.GetCurrentInstant().Minus(Duration.FromDays(3)));
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true);
var (_, _, reason) = await sut.CreateJobsAsync(@event, Rule(ignoreState: true)).SingleAsync();
A.CallTo(() => ruleTriggerHandler.Trigger(A<AppEvent>._, A<RuleTrigger>._, ruleId)) Assert.Equal(SkipReason.TooOld, reason);
A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, A<RuleContext>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_create_job_if_too_old_but_stale_events_are_not_ignored() public async Task Should_create_job_if_too_old_but_stale_events_are_not_ignored()
{ {
var @event = Envelope.Create(new ContentCreated()).SetTimestamp(clock.GetCurrentInstant().Minus(Duration.FromDays(3))); var context = Rule(ignoreState: false);
var @event =
Envelope.Create(new ContentCreated())
.SetTimestamp(clock.GetCurrentInstant().Minus(Duration.FromDays(3)));
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, context))
.Returns(true);
var jobs = await sut.CreateJobsAsync(ValidRule(), ruleId, @event, false); A.CallTo(() => ruleTriggerHandler.Trigger(A<EnrichedEvent>._, context))
.Returns(true);
var jobs = await sut.CreateJobsAsync(@event, context).ToListAsync();
Assert.Empty(jobs); Assert.Empty(jobs);
A.CallTo(() => ruleTriggerHandler.Trigger(A<AppEvent>._, A<RuleTrigger>._, ruleId)) A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, A<RuleContext>._))
.MustHaveHappened(); .MustHaveHappened();
} }
[Fact] [Fact]
public async Task Should_not_create_job_if_event_created_by_rule() public async Task Should_create_debug_job_if_event_created_by_rule()
{ {
var rule = ValidRule(); var context = Rule();
var @event = Envelope.Create(new ContentCreated { FromRule = true }); var @event = Envelope.Create(new ContentCreated { FromRule = true });
var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); var (_, _, reason) = await sut.CreateJobsAsync(@event, context).SingleAsync();
Assert.Empty(jobs); Assert.Equal(SkipReason.FromRule, reason);
A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) A.CallTo(() => ruleTriggerHandler.Trigger(A<Envelope<AppEvent>>._, A<RuleContext>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(A<Envelope<AppEvent>>._)) A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(A<Envelope<AppEvent>>._, A<RuleContext>._, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_not_create_job_if_not_triggered_with_precheck() public async Task Should_create_debug_job_if_not_triggered_with_precheck()
{ {
var rule = ValidRule(); var context = Rule();
var @event = Envelope.Create(new ContentCreated()); var @event = Envelope.Create(new ContentCreated());
A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
.Returns(false); .Returns(false);
var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); var (_, _, reason) = await sut.CreateJobsAsync(@event, context).SingleAsync();
Assert.Empty(jobs); Assert.Equal(SkipReason.ConditionDoesNotMatch, reason);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(A<Envelope<AppEvent>>._)) A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(A<Envelope<AppEvent>>._, A<RuleContext>._, default))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_not_create_job_if_enriched_event_not_created() public async Task Should_create_debug_job_if_condition_check_failed()
{ {
var rule = ValidRule(); var context = Rule();
var @event = Envelope.Create(new ContentCreated()); var @event = Envelope.Create(new ContentCreated());
A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true); .Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event))) A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
.Returns(new List<EnrichedEvent>()); .Throws(new InvalidOperationException());
var (_, _, reason) = await sut.CreateJobsAsync(@event, context).SingleAsync();
Assert.Equal(SkipReason.Failed, reason);
}
var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); [Fact]
public async Task Should_not_create_jobs_if_enriched_event_not_created()
{
var context = Rule();
var @event = Envelope.Create(new ContentCreated());
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default))
.Returns(AsyncEnumerable.Empty<EnrichedEvent>());
var jobs = await sut.CreateJobsAsync(@event, context).ToListAsync();
Assert.Empty(jobs); Assert.Empty(jobs);
} }
[Fact] [Fact]
public async Task Should_not_create_job_if_not_triggered() public async Task Should_create_debug_job_if_not_triggered()
{ {
var rule = ValidRule(); var context = Rule();
var enrichedEvent = new EnrichedContentEvent { AppId = appId }; var enrichedEvent = new EnrichedContentEvent { AppId = appId };
var @event = Envelope.Create(new ContentCreated()); var @event = Envelope.Create(new ContentCreated());
A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true); .Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event))) A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
.Returns(new List<EnrichedEvent> { enrichedEvent }); .Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, rule.Trigger)) A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, context))
.Returns(false); .Returns(false);
var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default))
.Returns(new List<EnrichedEvent> { enrichedEvent }.ToAsyncEnumerable());
Assert.Empty(jobs); var (_, _, reason) = await sut.CreateJobsAsync(@event, context).SingleAsync();
Assert.Equal(SkipReason.ConditionDoesNotMatch, reason);
}
[Fact]
public async Task Should_create_debug_job_if_enrichment_failed()
{
var now = clock.GetCurrentInstant();
var context = Rule();
var @event =
Envelope.Create(new ContentCreated())
.SetTimestamp(now);
A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default))
.Throws(new InvalidOperationException());
var (_, _, reason) = await sut.CreateJobsAsync(@event, context).SingleAsync();
Assert.Equal(SkipReason.Failed, reason);
} }
[Fact] [Fact]
@ -385,29 +522,32 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
{ {
var now = clock.GetCurrentInstant(); var now = clock.GetCurrentInstant();
var rule = ValidRule(); var context = Rule();
var enrichedEvent = new EnrichedContentEvent { AppId = appId }; var enrichedEvent = new EnrichedContentEvent { AppId = appId };
var @event = Envelope.Create(new ContentCreated()).SetTimestamp(now); var @event =
Envelope.Create(new ContentCreated())
.SetTimestamp(now);
A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true); .Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event))) A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
.Returns(new List<EnrichedEvent> { enrichedEvent }); .Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, rule.Trigger)) A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, context))
.Returns(true); .Returns(true);
A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent, rule.Action)) A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default))
.Returns((actionDescription, new ValidData { Value = 10 })); .Returns(new List<EnrichedEvent> { enrichedEvent }.ToAsyncEnumerable());
var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent, context.Rule.Action))
.Returns((actionDescription, new ValidData { Value = 10 }));
var (job, _) = jobs.Single(); var (job, _, _) = await sut.CreateJobsAsync(@event, context).SingleAsync();
AssertJob(now, enrichedEvent, job); AssertJob(now, enrichedEvent, job!);
A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent, MatchPayload(@event))) A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent, MatchPayload(@event)))
.MustHaveHappened(); .MustHaveHappened();
@ -418,31 +558,34 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
{ {
var now = clock.GetCurrentInstant(); var now = clock.GetCurrentInstant();
var rule = ValidRule(); var context = Rule();
var enrichedEvent = new EnrichedContentEvent { AppId = appId }; var enrichedEvent = new EnrichedContentEvent { AppId = appId };
var @event = Envelope.Create(new ContentCreated()).SetTimestamp(now); var @event =
Envelope.Create(new ContentCreated())
.SetTimestamp(now);
A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true); .Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event))) A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
.Returns(new List<EnrichedEvent> { enrichedEvent }); .Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, rule.Trigger)) A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, context))
.Returns(true); .Returns(true);
A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent, rule.Action)) A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default))
.Throws(new InvalidOperationException()); .Returns(new List<EnrichedEvent> { enrichedEvent }.ToAsyncEnumerable());
var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent, context.Rule.Action))
.Throws(new InvalidOperationException());
var (job, ex) = jobs.Single(); var (job, ex, _) = await sut.CreateJobsAsync(@event, context).SingleAsync();
Assert.NotNull(ex); Assert.NotNull(ex);
Assert.NotNull(job.ActionData); Assert.NotNull(job?.ActionData);
Assert.NotNull(job.Description); Assert.NotNull(job?.Description);
A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent, MatchPayload(@event))) A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent, MatchPayload(@event)))
.MustHaveHappened(); .MustHaveHappened();
@ -453,35 +596,40 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
{ {
var now = clock.GetCurrentInstant(); var now = clock.GetCurrentInstant();
var rule = ValidRule(); var context = Rule();
var enrichedEvent1 = new EnrichedContentEvent { AppId = appId }; var enrichedEvent1 = new EnrichedContentEvent { AppId = appId };
var enrichedEvent2 = new EnrichedContentEvent { AppId = appId }; var enrichedEvent2 = new EnrichedContentEvent { AppId = appId };
var @event = Envelope.Create(new ContentCreated()).SetTimestamp(now); var @event =
Envelope.Create(new ContentCreated())
.SetTimestamp(now);
A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId)) A.CallTo(() => ruleTriggerHandler.Handles(@event.Payload))
.Returns(true); .Returns(true);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event))) A.CallTo(() => ruleTriggerHandler.Trigger(MatchPayload(@event), context))
.Returns(new List<EnrichedEvent> { enrichedEvent1, enrichedEvent2 }); .Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent1, rule.Trigger)) A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent1, context))
.Returns(true); .Returns(true);
A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent2, rule.Trigger)) A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent2, context))
.Returns(true); .Returns(true);
A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent1, rule.Action)) A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventsAsync(MatchPayload(@event), context, default))
.Returns(new List<EnrichedEvent> { enrichedEvent1, enrichedEvent2 }.ToAsyncEnumerable());
A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent1, context.Rule.Action))
.Returns((actionDescription, new ValidData { Value = 10 })); .Returns((actionDescription, new ValidData { Value = 10 }));
A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent2, rule.Action)) A.CallTo(() => ruleActionHandler.CreateJobAsync(enrichedEvent2, context.Rule.Action))
.Returns((actionDescription, new ValidData { Value = 10 })); .Returns((actionDescription, new ValidData { Value = 10 }));
var jobs = await sut.CreateJobsAsync(rule, ruleId, @event); var jobs = await sut.CreateJobsAsync(@event, context, default).ToListAsync();
AssertJob(now, enrichedEvent1, jobs[0].Job); AssertJob(now, enrichedEvent1, jobs[0].Job!);
AssertJob(now, enrichedEvent1, jobs[1].Job); AssertJob(now, enrichedEvent1, jobs[1].Job!);
A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent1, MatchPayload(@event))) A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent1, MatchPayload(@event)))
.MustHaveHappened(); .MustHaveHappened();
@ -547,22 +695,45 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
Assert.Equal(ex, result.Result.Exception); Assert.Equal(ex, result.Result.Exception);
} }
private static Rule RuleInvalidAction() private RuleContext RuleInvalidAction()
{ {
return new Rule(new ContentChangedTriggerV2(), new InvalidAction()); return new RuleContext
{
AppId = appId,
Rule = new Rule(new ContentChangedTriggerV2(), new InvalidAction()),
RuleId = ruleId
};
} }
private static Rule RuleInvalidTrigger() private RuleContext RuleInvalidTrigger()
{ {
return new Rule(new InvalidTrigger(), new ValidAction()); return new RuleContext
{
AppId = appId,
Rule = new Rule(new InvalidTrigger(), new ValidAction()),
RuleId = ruleId
};
} }
private static Rule ValidRule() private RuleContext Rule(bool disable = false, bool ignoreState = true)
{ {
return new Rule(new ContentChangedTriggerV2(), new ValidAction()); var rule = new Rule(new ContentChangedTriggerV2(), new ValidAction());
if (disable)
{
rule = rule.Disable();
}
return new RuleContext
{
AppId = appId,
Rule = rule,
RuleId = ruleId,
IgnoreStale = ignoreState
};
} }
private static Envelope<AppEvent> MatchPayload(Envelope<ContentCreated> @event) private static Envelope<AppEvent> MatchPayload(Envelope<IEvent> @event)
{ {
return A<Envelope<AppEvent>>.That.Matches(x => x.Payload == @event.Payload); return A<Envelope<AppEvent>>.That.Matches(x => x.Payload == @event.Payload);
} }

1
backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj

@ -18,7 +18,6 @@
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" /> <PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />

124
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs

@ -11,6 +11,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
@ -29,7 +30,6 @@ namespace Squidex.Domain.Apps.Entities.Assets
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>(); private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
private readonly IAssetLoader assetLoader = A.Fake<IAssetLoader>(); private readonly IAssetLoader assetLoader = A.Fake<IAssetLoader>();
private readonly IAssetRepository assetRepository = A.Fake<IAssetRepository>(); private readonly IAssetRepository assetRepository = A.Fake<IAssetRepository>();
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly IRuleTriggerHandler sut; private readonly IRuleTriggerHandler sut;
public AssetChangedTriggerHandlerTests() public AssetChangedTriggerHandlerTests()
@ -51,93 +51,78 @@ namespace Squidex.Domain.Apps.Entities.Assets
yield return new object[] { new AssetDeleted(), EnrichedAssetEventType.Deleted }; yield return new object[] { new AssetDeleted(), EnrichedAssetEventType.Deleted };
} }
[Fact]
public void Should_return_true_if_asking_for_snapshot_support()
{
Assert.True(sut.CanCreateSnapshotEvents);
}
[Fact]
public void Should_handle_asset_event()
{
Assert.True(sut.Handles(new AssetCreated()));
}
[Fact]
public void Should_not_handle_asset_moved_event()
{
Assert.False(sut.Handles(new AssetMoved()));
}
[Fact]
public void Should_not_handle_other_event()
{
Assert.False(sut.Handles(new ContentCreated()));
}
[Fact] [Fact]
public async Task Should_create_events_from_snapshots() public async Task Should_create_events_from_snapshots()
{ {
var trigger = new AssetChangedTriggerV2(); var ctx = Context();
A.CallTo(() => assetRepository.StreamAll(appId.Id)) A.CallTo(() => assetRepository.StreamAll(ctx.AppId.Id, default))
.Returns(new List<AssetEntity> .Returns(new List<AssetEntity>
{ {
new AssetEntity(), new AssetEntity(),
new AssetEntity() new AssetEntity()
}.ToAsyncEnumerable()); }.ToAsyncEnumerable());
var result = await sut.CreateSnapshotEvents(trigger, appId.Id).ToListAsync(); var result = await sut.CreateSnapshotEventsAsync(ctx, default).ToListAsync();
var typed = result.OfType<EnrichedAssetEvent>().ToList(); var typed = result.OfType<EnrichedAssetEvent>().ToList();
Assert.Equal(2, typed.Count); Assert.Equal(2, typed.Count);
Assert.Equal(2, typed.Count(x => x.Type == EnrichedAssetEventType.Created)); Assert.Equal(2, typed.Count(x => x.Type == EnrichedAssetEventType.Created && x.Name == "AssetQueried"));
} }
[Theory] [Theory]
[MemberData(nameof(TestEvents))] [MemberData(nameof(TestEvents))]
public async Task Should_create_enriched_events(AssetEvent @event, EnrichedAssetEventType type) public async Task Should_create_enriched_events(AssetEvent @event, EnrichedAssetEventType type)
{ {
@event.AppId = appId; var ctx = Context();
@event.AppId = ctx.AppId;
var envelope = Envelope.Create<AppEvent>(@event).SetEventStreamNumber(12); var envelope = Envelope.Create<AppEvent>(@event).SetEventStreamNumber(12);
A.CallTo(() => assetLoader.GetAsync(appId.Id, @event.AssetId, 12)) A.CallTo(() => assetLoader.GetAsync(ctx.AppId.Id, @event.AssetId, 12))
.Returns(new AssetEntity()); .Returns(new AssetEntity());
var result = await sut.CreateEnrichedEventsAsync(envelope); var result = await sut.CreateEnrichedEventsAsync(envelope, ctx, default).ToListAsync();
var enrichedEvent = result.Single() as EnrichedAssetEvent; var enrichedEvent = result.Single() as EnrichedAssetEvent;
Assert.Equal(type, enrichedEvent!.Type); Assert.Equal(type, enrichedEvent!.Type);
} }
[Fact]
public async Task Should_skip_moved_event()
{
var envelope = Envelope.Create<AppEvent>(new AssetMoved());
var result = await sut.CreateEnrichedEventsAsync(envelope);
Assert.Empty(result);
}
[Fact]
public void Should_not_trigger_precheck_if_event_type_not_correct()
{
TestForCondition(string.Empty, trigger =>
{
var result = sut.Trigger(new ContentCreated(), trigger, DomainId.NewGuid());
Assert.False(result);
});
}
[Fact]
public void Should_trigger_precheck_if_event_type_correct()
{
TestForCondition(string.Empty, trigger =>
{
var result = sut.Trigger(new AssetCreated(), trigger, DomainId.NewGuid());
Assert.True(result);
});
}
[Fact]
public void Should_not_trigger_check_if_event_type_not_correct()
{
TestForCondition(string.Empty, trigger =>
{
var result = sut.Trigger(new EnrichedContentEvent(), trigger);
Assert.False(result);
});
}
[Fact] [Fact]
public void Should_trigger_check_if_condition_is_empty() public void Should_trigger_check_if_condition_is_empty()
{ {
TestForCondition(string.Empty, trigger => TestForCondition(string.Empty, ctx =>
{ {
var result = sut.Trigger(new EnrichedAssetEvent(), trigger); var @event = new EnrichedAssetEvent();
var result = sut.Trigger(@event, ctx);
Assert.True(result); Assert.True(result);
}); });
@ -146,9 +131,11 @@ namespace Squidex.Domain.Apps.Entities.Assets
[Fact] [Fact]
public void Should_trigger_check_if_condition_matchs() public void Should_trigger_check_if_condition_matchs()
{ {
TestForCondition("true", trigger => TestForCondition("true", ctx =>
{ {
var result = sut.Trigger(new EnrichedAssetEvent(), trigger); var @event = new EnrichedAssetEvent();
var result = sut.Trigger(@event, ctx);
Assert.True(result); Assert.True(result);
}); });
@ -157,19 +144,24 @@ namespace Squidex.Domain.Apps.Entities.Assets
[Fact] [Fact]
public void Should_not_trigger_check_if_condition_does_not_matchs() public void Should_not_trigger_check_if_condition_does_not_matchs()
{ {
TestForCondition("false", trigger => TestForCondition("false", ctx =>
{ {
var result = sut.Trigger(new EnrichedAssetEvent(), trigger); var @event = new EnrichedAssetEvent();
var result = sut.Trigger(@event, ctx);
Assert.False(result); Assert.False(result);
}); });
} }
private void TestForCondition(string condition, Action<AssetChangedTriggerV2> action) private void TestForCondition(string condition, Action<RuleContext> action)
{ {
var trigger = new AssetChangedTriggerV2 { Condition = condition }; var trigger = new AssetChangedTriggerV2
{
Condition = condition
};
action(trigger); action(Context(trigger));
if (string.IsNullOrWhiteSpace(condition)) if (string.IsNullOrWhiteSpace(condition))
{ {
@ -182,5 +174,17 @@ namespace Squidex.Domain.Apps.Entities.Assets
.MustHaveHappened(); .MustHaveHappened();
} }
} }
private static RuleContext Context(RuleTrigger? trigger = null)
{
trigger ??= new AssetChangedTriggerV2();
return new RuleContext
{
AppId = NamedId.Of(DomainId.NewGuid(), "my-app"),
Rule = new Rule(trigger, A.Fake<RuleAction>()),
RuleId = DomainId.NewGuid()
};
}
} }
} }

21
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/RepairFilesTests.cs

@ -5,8 +5,9 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Squidex.Assets; using Squidex.Assets;
@ -107,7 +108,14 @@ namespace Squidex.Domain.Apps.Entities.Assets
private void SetupEvent(IEvent? @event) private void SetupEvent(IEvent? @event)
{ {
var storedEvent = new StoredEvent("stream", "0", -1, new EventData("type", new EnvelopeHeaders(), "payload")); var storedEvent =
new StoredEvent("stream", "0", -1,
new EventData("type", new EnvelopeHeaders(), "payload"));
var storedEvents = new List<StoredEvent>
{
storedEvent
};
if (@event != null) if (@event != null)
{ {
@ -120,13 +128,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
.Returns(null); .Returns(null);
} }
A.CallTo(() => eventStore.QueryAsync(A<Func<StoredEvent, Task>>._, "^asset\\-", null, default)) A.CallTo(() => eventStore.QueryAllAsync("^asset\\-", null, long.MaxValue, default))
.Invokes(x => .Returns(storedEvents.ToAsyncEnumerable());
{
var callback = x.GetArgument<Func<StoredEvent, Task>>(0)!;
callback(storedEvent).Wait();
});
} }
} }
} }

173
backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentTriggerHandlerTests.cs

@ -13,6 +13,7 @@ using FakeItEasy;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
@ -50,21 +51,41 @@ namespace Squidex.Domain.Apps.Entities.Comments
Assert.False(sut.CanCreateSnapshotEvents); Assert.False(sut.CanCreateSnapshotEvents);
} }
[Fact]
public void Should_handle_comment_event()
{
Assert.True(sut.Handles(new CommentCreated()));
}
[Fact]
public void Should_not_handle_comment_update_event()
{
Assert.False(sut.Handles(new CommentUpdated()));
}
[Fact]
public void Should_not_handle_other_event()
{
Assert.False(sut.Handles(new ContentCreated()));
}
[Fact] [Fact]
public async Task Should_create_enriched_events() public async Task Should_create_enriched_events()
{ {
var ctx = Context();
var user1 = UserMocks.User("1"); var user1 = UserMocks.User("1");
var user2 = UserMocks.User("2"); var user2 = UserMocks.User("2");
var users = new List<IUser> { user1, user2 }; var users = new List<IUser> { user1, user2 };
var userIds = users.Select(x => x.Id).ToArray(); var userIds = users.Select(x => x.Id).ToArray();
var envelope = Envelope.Create<AppEvent>(new CommentCreated { Mentions = userIds }); var @event = new CommentCreated { Mentions = userIds };
A.CallTo(() => userResolver.QueryManyAsync(userIds)) A.CallTo(() => userResolver.QueryManyAsync(userIds))
.Returns(users.ToDictionary(x => x.Id)); .Returns(users.ToDictionary(x => x.Id));
var result = await sut.CreateEnrichedEventsAsync(envelope); var result = await sut.CreateEnrichedEventsAsync(Envelope.Create<AppEvent>(@event), ctx, default).ToListAsync();
Assert.Equal(2, result.Count); Assert.Equal(2, result.Count);
@ -80,15 +101,17 @@ namespace Squidex.Domain.Apps.Entities.Comments
[Fact] [Fact]
public async Task Should_not_create_enriched_events_if_users_cannot_be_resolved() public async Task Should_not_create_enriched_events_if_users_cannot_be_resolved()
{ {
var ctx = Context();
var user1 = UserMocks.User("1"); var user1 = UserMocks.User("1");
var user2 = UserMocks.User("2"); var user2 = UserMocks.User("2");
var users = new List<IUser> { user1, user2 }; var users = new List<IUser> { user1, user2 };
var userIds = users.Select(x => x.Id).ToArray(); var userIds = users.Select(x => x.Id).ToArray();
var envelope = Envelope.Create<AppEvent>(new CommentCreated { Mentions = userIds }); var @event = new CommentCreated { Mentions = userIds };
var result = await sut.CreateEnrichedEventsAsync(envelope); var result = await sut.CreateEnrichedEventsAsync(Envelope.Create<AppEvent>(@event), ctx, default).ToListAsync();
Assert.Empty(result); Assert.Empty(result);
} }
@ -96,22 +119,11 @@ namespace Squidex.Domain.Apps.Entities.Comments
[Fact] [Fact]
public async Task Should_not_create_enriched_events_if_mentions_is_null() public async Task Should_not_create_enriched_events_if_mentions_is_null()
{ {
var envelope = Envelope.Create<AppEvent>(new CommentCreated { Mentions = null }); var ctx = Context();
var result = await sut.CreateEnrichedEventsAsync(envelope);
Assert.Empty(result); var @event = new CommentCreated { Mentions = null };
A.CallTo(() => userResolver.QueryManyAsync(A<string[]>._))
.MustNotHaveHappened();
}
[Fact] var result = await sut.CreateEnrichedEventsAsync(Envelope.Create<AppEvent>(@event), ctx, default).ToListAsync();
public async Task Should_not_create_enriched_events_if_mentions_is_empty()
{
var envelope = Envelope.Create<AppEvent>(new CommentCreated { Mentions = Array.Empty<string>() });
var result = await sut.CreateEnrichedEventsAsync(envelope);
Assert.Empty(result); Assert.Empty(result);
@ -120,24 +132,13 @@ namespace Squidex.Domain.Apps.Entities.Comments
} }
[Fact] [Fact]
public async Task Should_skip_udated_event() public async Task Should_not_create_enriched_events_if_mentions_is_empty()
{ {
var envelope = Envelope.Create<AppEvent>(new CommentUpdated()); var ctx = Context();
var result = await sut.CreateEnrichedEventsAsync(envelope);
Assert.Empty(result);
A.CallTo(() => userResolver.QueryManyAsync(A<string[]>._))
.MustNotHaveHappened();
}
[Fact] var @event = new CommentCreated { Mentions = Array.Empty<string>() };
public async Task Should_skip_deleted_event()
{
var envelope = Envelope.Create<AppEvent>(new CommentDeleted());
var result = await sut.CreateEnrichedEventsAsync(envelope); var result = await sut.CreateEnrichedEventsAsync(Envelope.Create<AppEvent>(@event), ctx, default).ToListAsync();
Assert.Empty(result); Assert.Empty(result);
@ -145,45 +146,27 @@ namespace Squidex.Domain.Apps.Entities.Comments
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact]
public void Should_not_trigger_precheck_if_event_type_not_correct()
{
TestForCondition(string.Empty, trigger =>
{
var result = sut.Trigger(new ContentCreated(), trigger, DomainId.NewGuid());
Assert.False(result);
});
}
[Fact] [Fact]
public void Should_trigger_precheck_if_event_type_correct() public void Should_trigger_precheck_if_event_type_correct()
{ {
TestForCondition(string.Empty, trigger => TestForCondition(string.Empty, ctx =>
{ {
var result = sut.Trigger(new CommentCreated(), trigger, DomainId.NewGuid()); var @event = new CommentCreated();
Assert.True(result);
});
}
[Fact] var result = sut.Trigger(Envelope.Create<AppEvent>(@event), ctx);
public void Should_not_trigger_check_if_event_type_not_correct()
{
TestForCondition(string.Empty, trigger =>
{
var result = sut.Trigger(new EnrichedContentEvent(), trigger);
Assert.False(result); Assert.True(result);
}); });
} }
[Fact] [Fact]
public void Should_trigger_check_if_condition_is_empty() public void Should_trigger_check_if_condition_is_empty()
{ {
TestForCondition(string.Empty, trigger => TestForCondition(string.Empty, ctx =>
{ {
var result = sut.Trigger(new EnrichedCommentEvent(), trigger); var @event = new EnrichedCommentEvent();
var result = sut.Trigger(@event, ctx);
Assert.True(result); Assert.True(result);
}); });
@ -192,20 +175,24 @@ namespace Squidex.Domain.Apps.Entities.Comments
[Fact] [Fact]
public void Should_trigger_check_if_condition_matchs() public void Should_trigger_check_if_condition_matchs()
{ {
TestForCondition("true", trigger => TestForCondition("true", ctx =>
{ {
var result = sut.Trigger(new EnrichedCommentEvent(), trigger); var @event = new EnrichedCommentEvent();
var result = sut.Trigger(new EnrichedCommentEvent(), ctx);
Assert.True(result); Assert.True(result);
}); });
} }
[Fact] [Fact]
public void Should_not_trigger_check_if_condition_does_not_matchs() public void Should_not_trigger_check_if_condition_does_not_match()
{ {
TestForCondition("false", trigger => TestForCondition("false", ctx =>
{ {
var result = sut.Trigger(new EnrichedCommentEvent(), trigger); var @event = new EnrichedCommentEvent();
var result = sut.Trigger(@event, ctx);
Assert.False(result); Assert.False(result);
}); });
@ -214,24 +201,30 @@ namespace Squidex.Domain.Apps.Entities.Comments
[Fact] [Fact]
public void Should_trigger_check_if_email_is_correct() public void Should_trigger_check_if_email_is_correct()
{ {
TestForRealCondition("event.mentionedUser.email == '1@email.com'", (handler, trigger) => TestForRealCondition("event.mentionedUser.email == '1@email.com'", (handler, ctx) =>
{ {
var user = UserMocks.User("1", "1@email.com"); var @event = new EnrichedCommentEvent
{
MentionedUser = UserMocks.User("1", "1@email.com")
};
var result = handler.Trigger(new EnrichedCommentEvent { MentionedUser = user }, trigger); var result = handler.Trigger(@event, ctx);
Assert.True(result); Assert.True(result);
}); });
} }
[Fact] [Fact]
public void Should_not_trigger_check_if_email_is_correct() public void Should_not_trigger_check_if_email_is_not_correct()
{ {
TestForRealCondition("event.mentionedUser.email == 'other@squidex.io'", (handler, trigger) => TestForRealCondition("event.mentionedUser.email == 'other@squidex.io'", (handler, ctx) =>
{ {
var user = UserMocks.User("1"); var @event = new EnrichedCommentEvent
{
MentionedUser = UserMocks.User("1", "1@email.com")
};
var result = handler.Trigger(new EnrichedCommentEvent { MentionedUser = user }, trigger); var result = handler.Trigger(@event, ctx);
Assert.False(result); Assert.False(result);
}); });
@ -240,11 +233,14 @@ namespace Squidex.Domain.Apps.Entities.Comments
[Fact] [Fact]
public void Should_trigger_check_if_text_is_urgent() public void Should_trigger_check_if_text_is_urgent()
{ {
TestForRealCondition("event.text.indexOf('urgent') >= 0", (handler, trigger) => TestForRealCondition("event.text.indexOf('urgent') >= 0", (handler, ctx) =>
{ {
var text = "Hey man, this is really urgent."; var @event = new EnrichedCommentEvent
{
Text = "very_urgent_text"
};
var result = handler.Trigger(new EnrichedCommentEvent { Text = text }, trigger); var result = handler.Trigger(@event, ctx);
Assert.True(result); Assert.True(result);
}); });
@ -253,17 +249,20 @@ namespace Squidex.Domain.Apps.Entities.Comments
[Fact] [Fact]
public void Should_not_trigger_check_if_text_is_not_urgent() public void Should_not_trigger_check_if_text_is_not_urgent()
{ {
TestForRealCondition("event.text.indexOf('urgent') >= 0", (handler, trigger) => TestForRealCondition("event.text.indexOf('urgent') >= 0", (handler, ctx) =>
{ {
var text = "Hey man, just an information for you."; var @event = new EnrichedCommentEvent
{
Text = "just_gossip"
};
var result = handler.Trigger(new EnrichedCommentEvent { Text = text }, trigger); var result = handler.Trigger(@event, ctx);
Assert.False(result); Assert.False(result);
}); });
} }
private void TestForRealCondition(string condition, Action<IRuleTriggerHandler, CommentTrigger> action) private void TestForRealCondition(string condition, Action<IRuleTriggerHandler, RuleContext> action)
{ {
var trigger = new CommentTrigger var trigger = new CommentTrigger
{ {
@ -274,17 +273,17 @@ namespace Squidex.Domain.Apps.Entities.Comments
var handler = new CommentTriggerHandler(new JintScriptEngine(memoryCache), userResolver); var handler = new CommentTriggerHandler(new JintScriptEngine(memoryCache), userResolver);
action(handler, trigger); action(handler, Context(trigger));
} }
private void TestForCondition(string condition, Action<CommentTrigger> action) private void TestForCondition(string condition, Action<RuleContext> action)
{ {
var trigger = new CommentTrigger var trigger = new CommentTrigger
{ {
Condition = condition Condition = condition
}; };
action(trigger); action(Context(trigger));
if (string.IsNullOrWhiteSpace(condition)) if (string.IsNullOrWhiteSpace(condition))
{ {
@ -297,5 +296,17 @@ namespace Squidex.Domain.Apps.Entities.Comments
.MustHaveHappened(); .MustHaveHappened();
} }
} }
private static RuleContext Context(RuleTrigger? trigger = null)
{
trigger ??= new CommentTrigger();
return new RuleContext
{
AppId = NamedId.Of(DomainId.NewGuid(), "my-app"),
Rule = new Rule(trigger, A.Fake<RuleAction>()),
RuleId = DomainId.NewGuid()
};
}
} }
} }

162
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs

@ -13,6 +13,7 @@ using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
@ -32,10 +33,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>(); private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
private readonly IContentLoader contentLoader = A.Fake<IContentLoader>(); private readonly IContentLoader contentLoader = A.Fake<IContentLoader>();
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>(); private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly NamedId<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly NamedId<DomainId> schemaMatch = NamedId.Of(DomainId.NewGuid(), "my-schema1"); private readonly NamedId<DomainId> schemaMatch = NamedId.Of(DomainId.NewGuid(), "my-schema1");
private readonly NamedId<DomainId> schemaNonMatch = NamedId.Of(DomainId.NewGuid(), "my-schema2"); private readonly NamedId<DomainId> schemaNonMatch = NamedId.Of(DomainId.NewGuid(), "my-schema2");
private readonly DomainId ruleId = DomainId.NewGuid();
private readonly IRuleTriggerHandler sut; private readonly IRuleTriggerHandler sut;
public ContentChangedTriggerHandlerTests() public ContentChangedTriggerHandlerTests()
@ -65,24 +64,47 @@ namespace Squidex.Domain.Apps.Entities.Contents
Assert.True(sut.CanCreateSnapshotEvents); Assert.True(sut.CanCreateSnapshotEvents);
} }
[Fact]
public void Should_handle_content_event()
{
Assert.True(sut.Handles(new ContentCreated()));
}
[Fact]
public void Should_not_handle_other_event()
{
Assert.False(sut.Handles(new AssetMoved()));
}
[Fact]
public void Should_calculate_name()
{
var @event = new ContentCreated { SchemaId = schemaMatch };
Assert.Equal("ContentCreated(MySchema1)", sut.GetName(@event));
}
[Fact] [Fact]
public async Task Should_create_events_from_snapshots() public async Task Should_create_events_from_snapshots()
{ {
var trigger = new ContentChangedTriggerV2(); var ctx = Context();
A.CallTo(() => contentRepository.StreamAll(appId.Id, null)) A.CallTo(() => contentRepository.StreamAll(ctx.AppId.Id, null, default))
.Returns(new List<ContentEntity> .Returns(new List<ContentEntity>
{ {
new ContentEntity { SchemaId = schemaMatch }, new ContentEntity { SchemaId = schemaMatch },
new ContentEntity { SchemaId = schemaMatch } new ContentEntity { SchemaId = schemaNonMatch }
}.ToAsyncEnumerable()); }.ToAsyncEnumerable());
var result = await sut.CreateSnapshotEvents(trigger, appId.Id).ToListAsync(); var result = await sut.CreateSnapshotEventsAsync(ctx, default).ToListAsync();
var typed = result.OfType<EnrichedContentEvent>().ToList(); var typed = result.OfType<EnrichedContentEvent>().ToList();
Assert.Equal(2, typed.Count); Assert.Equal(2, typed.Count);
Assert.Equal(2, typed.Count(x => x.Type == EnrichedContentEventType.Created)); Assert.Equal(2, typed.Count(x => x.Type == EnrichedContentEventType.Created));
Assert.Equal("ContentQueried(MySchema1)", typed[0].Name);
Assert.Equal("ContentQueried(MySchema2)", typed[1].Name);
} }
[Fact] [Fact]
@ -99,14 +121,16 @@ namespace Squidex.Domain.Apps.Entities.Contents
}) })
}; };
A.CallTo(() => contentRepository.StreamAll(appId.Id, A<HashSet<DomainId>>.That.Is(schemaMatch.Id))) var ctx = Context(trigger);
A.CallTo(() => contentRepository.StreamAll(ctx.AppId.Id, A<HashSet<DomainId>>.That.Is(schemaMatch.Id), default))
.Returns(new List<ContentEntity> .Returns(new List<ContentEntity>
{ {
new ContentEntity { SchemaId = schemaMatch }, new ContentEntity { SchemaId = schemaMatch },
new ContentEntity { SchemaId = schemaMatch } new ContentEntity { SchemaId = schemaMatch }
}.ToAsyncEnumerable()); }.ToAsyncEnumerable());
var result = await sut.CreateSnapshotEvents(trigger, appId.Id).ToListAsync(); var result = await sut.CreateSnapshotEventsAsync(ctx, default).ToListAsync();
var typed = result.OfType<EnrichedContentEvent>().ToList(); var typed = result.OfType<EnrichedContentEvent>().ToList();
@ -118,15 +142,17 @@ namespace Squidex.Domain.Apps.Entities.Contents
[MemberData(nameof(TestEvents))] [MemberData(nameof(TestEvents))]
public async Task Should_create_enriched_events(ContentEvent @event, EnrichedContentEventType type) public async Task Should_create_enriched_events(ContentEvent @event, EnrichedContentEventType type)
{ {
@event.AppId = appId; var ctx = Context();
@event.AppId = ctx.AppId;
@event.SchemaId = schemaMatch; @event.SchemaId = schemaMatch;
var envelope = Envelope.Create<AppEvent>(@event).SetEventStreamNumber(12); var envelope = Envelope.Create<AppEvent>(@event).SetEventStreamNumber(12);
A.CallTo(() => contentLoader.GetAsync(appId.Id, @event.ContentId, 12)) A.CallTo(() => contentLoader.GetAsync(ctx.AppId.Id, @event.ContentId, 12))
.Returns(new ContentEntity { AppId = appId, SchemaId = schemaMatch }); .Returns(new ContentEntity { AppId = ctx.AppId, SchemaId = schemaMatch });
var result = await sut.CreateEnrichedEventsAsync(envelope); var result = await sut.CreateEnrichedEventsAsync(envelope, ctx, default).ToListAsync();
var enrichedEvent = result.Single() as EnrichedContentEvent; var enrichedEvent = result.Single() as EnrichedContentEvent;
@ -136,20 +162,22 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public async Task Should_enrich_with_old_data_if_updated() public async Task Should_enrich_with_old_data_if_updated()
{ {
var @event = new ContentUpdated { AppId = appId, ContentId = DomainId.NewGuid(), SchemaId = schemaMatch }; var ctx = Context();
var @event = new ContentUpdated { AppId = ctx.AppId, ContentId = DomainId.NewGuid(), SchemaId = schemaMatch };
var envelope = Envelope.Create<AppEvent>(@event).SetEventStreamNumber(12); var envelope = Envelope.Create<AppEvent>(@event).SetEventStreamNumber(12);
var dataNow = new ContentData(); var dataNow = new ContentData();
var dataOld = new ContentData(); var dataOld = new ContentData();
A.CallTo(() => contentLoader.GetAsync(appId.Id, @event.ContentId, 12)) A.CallTo(() => contentLoader.GetAsync(ctx.AppId.Id, @event.ContentId, 12))
.Returns(new ContentEntity { AppId = appId, SchemaId = schemaMatch, Version = 12, Data = dataNow, Id = @event.ContentId }); .Returns(new ContentEntity { AppId = ctx.AppId, SchemaId = schemaMatch, Version = 12, Data = dataNow, Id = @event.ContentId });
A.CallTo(() => contentLoader.GetAsync(appId.Id, @event.ContentId, 11)) A.CallTo(() => contentLoader.GetAsync(ctx.AppId.Id, @event.ContentId, 11))
.Returns(new ContentEntity { AppId = appId, SchemaId = schemaMatch, Version = 11, Data = dataOld }); .Returns(new ContentEntity { AppId = ctx.AppId, SchemaId = schemaMatch, Version = 11, Data = dataOld });
var result = await sut.CreateEnrichedEventsAsync(envelope); var result = await sut.CreateEnrichedEventsAsync(envelope, ctx, default).ToListAsync();
var enrichedEvent = result.Single() as EnrichedContentEvent; var enrichedEvent = result.Single() as EnrichedContentEvent;
@ -157,23 +185,14 @@ namespace Squidex.Domain.Apps.Entities.Contents
Assert.Same(dataOld, enrichedEvent!.DataOld); Assert.Same(dataOld, enrichedEvent!.DataOld);
} }
[Fact]
public void Should_not_trigger_precheck_if_event_type_not_correct()
{
TestForTrigger(handleAll: true, schemaId: null, condition: null, action: trigger =>
{
var result = sut.Trigger(new AssetCreated(), trigger, ruleId);
Assert.False(result);
});
}
[Fact] [Fact]
public void Should_not_trigger_precheck_if_trigger_contains_no_schemas() public void Should_not_trigger_precheck_if_trigger_contains_no_schemas()
{ {
TestForTrigger(handleAll: false, schemaId: null, condition: null, action: trigger => TestForTrigger(handleAll: false, schemaId: null, condition: null, action: ctx =>
{ {
var result = sut.Trigger(new ContentCreated { SchemaId = schemaMatch }, trigger, ruleId); var @event = new ContentCreated { SchemaId = schemaMatch };
var result = sut.Trigger(Envelope.Create<AppEvent>(@event), ctx);
Assert.False(result); Assert.False(result);
}); });
@ -182,9 +201,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public void Should_trigger_precheck_if_handling_all_events() public void Should_trigger_precheck_if_handling_all_events()
{ {
TestForTrigger(handleAll: true, schemaId: schemaMatch, condition: null, action: trigger => TestForTrigger(handleAll: true, schemaId: schemaMatch, condition: null, action: ctx =>
{ {
var result = sut.Trigger(new ContentCreated { SchemaId = schemaMatch }, trigger, ruleId); var @event = new ContentCreated { SchemaId = schemaMatch };
var result = sut.Trigger(Envelope.Create<AppEvent>(@event), ctx);
Assert.True(result); Assert.True(result);
}); });
@ -193,9 +214,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public void Should_trigger_precheck_if_condition_is_empty() public void Should_trigger_precheck_if_condition_is_empty()
{ {
TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: string.Empty, action: trigger => TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: string.Empty, action: ctx =>
{ {
var result = sut.Trigger(new ContentCreated { SchemaId = schemaMatch }, trigger, ruleId); var @event = new ContentCreated { SchemaId = schemaMatch };
var result = sut.Trigger(Envelope.Create<AppEvent>(@event), ctx);
Assert.True(result); Assert.True(result);
}); });
@ -204,20 +227,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public void Should_not_trigger_precheck_if_schema_id_does_not_match() public void Should_not_trigger_precheck_if_schema_id_does_not_match()
{ {
TestForTrigger(handleAll: false, schemaId: schemaNonMatch, condition: null, action: trigger => TestForTrigger(handleAll: false, schemaId: schemaNonMatch, condition: null, action: ctx =>
{ {
var result = sut.Trigger(new ContentCreated { SchemaId = schemaMatch }, trigger, ruleId); var @event = new ContentCreated { SchemaId = schemaMatch };
Assert.False(result);
});
}
[Fact] var result = sut.Trigger(Envelope.Create<AppEvent>(@event), ctx);
public void Should_not_trigger_check_if_event_type_not_correct()
{
TestForTrigger(handleAll: true, schemaId: null, condition: null, action: trigger =>
{
var result = sut.Trigger(new EnrichedAssetEvent(), trigger);
Assert.False(result); Assert.False(result);
}); });
@ -226,9 +240,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public void Should_not_trigger_check_if_trigger_contains_no_schemas() public void Should_not_trigger_check_if_trigger_contains_no_schemas()
{ {
TestForTrigger(handleAll: false, schemaId: null, condition: null, action: trigger => TestForTrigger(handleAll: false, schemaId: null, condition: null, action: ctx =>
{ {
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = schemaMatch }, trigger); var @event = new EnrichedContentEvent { SchemaId = schemaMatch };
var result = sut.Trigger(@event, ctx);
Assert.False(result); Assert.False(result);
}); });
@ -237,9 +253,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public void Should_trigger_check_if_handling_all_events() public void Should_trigger_check_if_handling_all_events()
{ {
TestForTrigger(handleAll: true, schemaId: schemaMatch, condition: null, action: trigger => TestForTrigger(handleAll: true, schemaId: schemaMatch, condition: null, action: ctx =>
{ {
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = schemaMatch }, trigger); var @event = new EnrichedContentEvent { SchemaId = schemaMatch };
var result = sut.Trigger(@event, ctx);
Assert.True(result); Assert.True(result);
}); });
@ -248,9 +266,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public void Should_trigger_check_if_condition_is_empty() public void Should_trigger_check_if_condition_is_empty()
{ {
TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: string.Empty, action: trigger => TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: string.Empty, action: ctx =>
{ {
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = schemaMatch }, trigger); var @event = new EnrichedContentEvent { SchemaId = schemaMatch };
var result = sut.Trigger(@event, ctx);
Assert.True(result); Assert.True(result);
}); });
@ -259,9 +279,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public void Should_trigger_check_if_condition_matchs() public void Should_trigger_check_if_condition_matchs()
{ {
TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: "true", action: trigger => TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: "true", action: ctx =>
{ {
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = schemaMatch }, trigger); var @event = new EnrichedContentEvent { SchemaId = schemaMatch };
var result = sut.Trigger(@event, ctx);
Assert.True(result); Assert.True(result);
}); });
@ -270,26 +292,30 @@ namespace Squidex.Domain.Apps.Entities.Contents
[Fact] [Fact]
public void Should_not_trigger_check_if_schema_id_does_not_match() public void Should_not_trigger_check_if_schema_id_does_not_match()
{ {
TestForTrigger(handleAll: false, schemaId: schemaNonMatch, condition: null, action: trigger => TestForTrigger(handleAll: false, schemaId: schemaNonMatch, condition: null, action: ctx =>
{ {
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = schemaMatch }, trigger); var @event = new EnrichedContentEvent { SchemaId = schemaMatch };
var result = sut.Trigger(@event, ctx);
Assert.False(result); Assert.False(result);
}); });
} }
[Fact] [Fact]
public void Should_not_trigger_check_if_condition_does_not_matchs() public void Should_not_trigger_check_if_condition_does_not_match()
{ {
TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: "false", action: trigger => TestForTrigger(handleAll: false, schemaId: schemaMatch, condition: "false", action: ctx =>
{ {
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = schemaMatch }, trigger); var @event = new EnrichedContentEvent { SchemaId = schemaMatch };
var result = sut.Trigger(@event, ctx);
Assert.False(result); Assert.False(result);
}); });
} }
private void TestForTrigger(bool handleAll, NamedId<DomainId>? schemaId, string? condition, Action<ContentChangedTriggerV2> action) private void TestForTrigger(bool handleAll, NamedId<DomainId>? schemaId, string? condition, Action<RuleContext> action)
{ {
var trigger = new ContentChangedTriggerV2 { HandleAll = handleAll }; var trigger = new ContentChangedTriggerV2 { HandleAll = handleAll };
@ -304,7 +330,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}); });
} }
action(trigger); action(Context(trigger));
if (string.IsNullOrWhiteSpace(condition)) if (string.IsNullOrWhiteSpace(condition))
{ {
@ -317,5 +343,17 @@ namespace Squidex.Domain.Apps.Entities.Contents
.MustHaveHappened(); .MustHaveHappened();
} }
} }
private static RuleContext Context(RuleTrigger? trigger = null)
{
trigger ??= new ContentChangedTriggerV2();
return new RuleContext
{
AppId = NamedId.Of(DomainId.NewGuid(), "my-app"),
Rule = new Rule(trigger, A.Fake<RuleAction>()),
RuleId = DomainId.NewGuid()
};
}
} }
} }

31
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/ManualTriggerHandlerTests.cs

@ -9,7 +9,6 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Rules; using Squidex.Domain.Apps.Events.Rules;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -28,14 +27,22 @@ namespace Squidex.Domain.Apps.Entities.Rules
Assert.False(sut.CanCreateSnapshotEvents); Assert.False(sut.CanCreateSnapshotEvents);
} }
[Fact]
public void Should_calculate_name()
{
var @event = new RuleManuallyTriggered();
Assert.Equal("Manual", sut.GetName(@event));
}
[Fact] [Fact]
public async Task Should_create_event_with_name() public async Task Should_create_event_with_name()
{ {
var envelope = Envelope.Create<AppEvent>(new RuleManuallyTriggered()); var @event = new RuleManuallyTriggered();
var result = await sut.CreateEnrichedEventsAsync(envelope); var result = await sut.CreateEnrichedEventsAsync(Envelope.Create<AppEvent>(@event), default, default).ToListAsync();
Assert.Equal("Manual", result.Single().Name); Assert.NotEmpty(result);
} }
[Fact] [Fact]
@ -43,9 +50,9 @@ namespace Squidex.Domain.Apps.Entities.Rules
{ {
var actor = RefToken.User("me"); var actor = RefToken.User("me");
var envelope = Envelope.Create<AppEvent>(new RuleManuallyTriggered { Actor = actor }); var @event = new RuleManuallyTriggered { Actor = actor };
var result = await sut.CreateEnrichedEventsAsync(envelope); var result = await sut.CreateEnrichedEventsAsync(Envelope.Create<AppEvent>(@event), default, default).ToListAsync();
Assert.Equal(actor, ((EnrichedUserEventBase)result.Single()).Actor); Assert.Equal(actor, ((EnrichedUserEventBase)result.Single()).Actor);
} }
@ -53,7 +60,17 @@ namespace Squidex.Domain.Apps.Entities.Rules
[Fact] [Fact]
public void Should_always_trigger() public void Should_always_trigger()
{ {
Assert.True(sut.Trigger(new EnrichedManualEvent(), new ManualTrigger())); var @event = new RuleManuallyTriggered();
Assert.True(sut.Trigger(Envelope.Create<AppEvent>(@event), default));
}
[Fact]
public void Should_always_trigger_enriched_event()
{
var @event = new EnrichedUsageExceededEvent();
Assert.True(sut.Trigger(@event, default));
} }
} }
} }

46
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs

@ -7,6 +7,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
@ -74,6 +75,27 @@ namespace Squidex.Domain.Apps.Entities.Rules
Assert.Equal(nameof(RuleEnqueuer), consumer.Name); Assert.Equal(nameof(RuleEnqueuer), consumer.Name);
} }
[Fact]
public async Task Should_not_insert_job_if_null()
{
var @event = Envelope.Create<IEvent>(new ContentCreated { AppId = appId });
var rule = CreateRule();
var job = new RuleJob
{
Created = now
};
A.CallTo(() => ruleService.CreateJobsAsync(@event, A<RuleContext>.That.Matches(x => x.Rule == rule.RuleDef), default))
.Returns(new List<JobResult> { new JobResult(null) }.ToAsyncEnumerable());
await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event);
A.CallTo(() => ruleEventRepository.EnqueueAsync(A<RuleJob>._, (Exception?)null))
.MustNotHaveHappened();
}
[Fact] [Fact]
public async Task Should_update_repository_if_enqueing() public async Task Should_update_repository_if_enqueing()
{ {
@ -81,10 +103,13 @@ namespace Squidex.Domain.Apps.Entities.Rules
var rule = CreateRule(); var rule = CreateRule();
var job = new RuleJob { Created = now }; var job = new RuleJob
{
Created = now
};
A.CallTo(() => ruleService.CreateJobsAsync(rule.RuleDef, rule.Id, @event, true)) A.CallTo(() => ruleService.CreateJobsAsync(@event, A<RuleContext>.That.Matches(x => x.Rule == rule.RuleDef), default))
.Returns(new List<(RuleJob, Exception?)> { (job, null) }); .Returns(new List<JobResult> { new JobResult(job) }.ToAsyncEnumerable());
await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event); await sut.EnqueueAsync(rule.RuleDef, rule.Id, @event);
@ -93,11 +118,14 @@ namespace Squidex.Domain.Apps.Entities.Rules
} }
[Fact] [Fact]
public async Task Should_update_repositories_with_jobs_from_service() public async Task Should_update_repository_with_jobs_from_service()
{ {
var @event = Envelope.Create<IEvent>(new ContentCreated { AppId = appId }); var @event = Envelope.Create<IEvent>(new ContentCreated { AppId = appId });
var job1 = new RuleJob { Created = now }; var job1 = new RuleJob
{
Created = now
};
SetupRules(@event, job1); SetupRules(@event, job1);
@ -130,11 +158,11 @@ namespace Squidex.Domain.Apps.Entities.Rules
A.CallTo(() => appProvider.GetRulesAsync(appId.Id)) A.CallTo(() => appProvider.GetRulesAsync(appId.Id))
.Returns(new List<IRuleEntity> { rule1, rule2 }); .Returns(new List<IRuleEntity> { rule1, rule2 });
A.CallTo(() => ruleService.CreateJobsAsync(rule1.RuleDef, rule1.Id, @event, true)) A.CallTo(() => ruleService.CreateJobsAsync(@event, A<RuleContext>.That.Matches(x => x.Rule == rule1.RuleDef), default))
.Returns(new List<(RuleJob, Exception?)> { (job1, null) }); .Returns(new List<JobResult> { new JobResult(job1) }.ToAsyncEnumerable());
A.CallTo(() => ruleService.CreateJobsAsync(rule2.RuleDef, rule2.Id, @event, true)) A.CallTo(() => ruleService.CreateJobsAsync(@event, A<RuleContext>.That.Matches(x => x.Rule == rule2.RuleDef), default))
.Returns(new List<(RuleJob, Exception?)>()); .Returns(new List<JobResult>().ToAsyncEnumerable());
} }
private static RuleEntity CreateRule() private static RuleEntity CreateRule()

61
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/UsageTracking/UsageTriggerHandlerTests.cs

@ -7,7 +7,9 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
@ -20,7 +22,6 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
{ {
public class UsageTriggerHandlerTests public class UsageTriggerHandlerTests
{ {
private readonly DomainId ruleId = DomainId.NewGuid();
private readonly IRuleTriggerHandler sut = new UsageTriggerHandler(); private readonly IRuleTriggerHandler sut = new UsageTriggerHandler();
[Fact] [Fact]
@ -30,48 +31,66 @@ namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
} }
[Fact] [Fact]
public void Should_not_trigger_precheck_if_event_type_not_correct() public void Should_handle_usage_event()
{ {
var result = sut.Trigger(new ContentCreated(), new UsageTrigger(), ruleId); Assert.True(sut.Handles(new AppUsageExceeded()));
Assert.False(result);
} }
[Fact] [Fact]
public void Should_not_trigger_precheck_if_rule_id_not_matchs() public void Should_not_handle_other_event()
{ {
var result = sut.Trigger(new AppUsageExceeded { RuleId = DomainId.NewGuid() }, new UsageTrigger(), ruleId); Assert.False(sut.Handles(new ContentCreated()));
Assert.True(result);
} }
[Fact] [Fact]
public void Should_trigger_precheck_if_event_type_correct_and_rule_id_matchs() public async Task Should_create_enriched_event()
{ {
var result = sut.Trigger(new AppUsageExceeded { RuleId = ruleId }, new UsageTrigger(), ruleId); var ctx = Context();
Assert.True(result); var @event = new AppUsageExceeded { CallsCurrent = 80, CallsLimit = 120 };
var result = await sut.CreateEnrichedEventsAsync(Envelope.Create<AppEvent>(@event), ctx, default).ToListAsync();
var enrichedEvent = result.Single() as EnrichedUsageExceededEvent;
Assert.Equal(@event.CallsCurrent, enrichedEvent!.CallsCurrent);
Assert.Equal(@event.CallsLimit, enrichedEvent!.CallsLimit);
} }
[Fact] [Fact]
public void Should_not_trigger_check_if_event_type_not_correct() public void Should_not_trigger_precheck_if_rule_id_not_matchs()
{ {
var result = sut.Trigger(new EnrichedContentEvent(), new UsageTrigger()); var ctx = Context();
Assert.False(result); var @event = new AppUsageExceeded();
var result = sut.Trigger(Envelope.Create<AppEvent>(@event), ctx);
Assert.True(result);
} }
[Fact] [Fact]
public async Task Should_create_enriched_event() public void Should_trigger_precheck_if_event_type_correct_and_rule_id_matchs()
{ {
var @event = new AppUsageExceeded { CallsCurrent = 80, CallsLimit = 120 }; var ctx = Context();
var result = await sut.CreateEnrichedEventsAsync(Envelope.Create<AppEvent>(@event)); var @event = new AppUsageExceeded { RuleId = ctx.RuleId };
var enrichedEvent = result.Single() as EnrichedUsageExceededEvent; var result = sut.Trigger(Envelope.Create<AppEvent>(@event), ctx);
Assert.Equal(@event.CallsCurrent, enrichedEvent!.CallsCurrent); Assert.True(result);
Assert.Equal(@event.CallsLimit, enrichedEvent!.CallsLimit); }
private static RuleContext Context(RuleTrigger? trigger = null)
{
trigger ??= new UsageTrigger();
return new RuleContext
{
AppId = NamedId.Of(DomainId.NewGuid(), "my-app"),
Rule = new Rule(trigger, A.Fake<RuleAction>()),
RuleId = DomainId.NewGuid()
};
} }
} }
} }

86
backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/SchemaChangedTriggerHandlerTests.cs

@ -11,6 +11,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
@ -54,58 +55,54 @@ namespace Squidex.Domain.Apps.Entities.Schemas
Assert.False(sut.CanCreateSnapshotEvents); Assert.False(sut.CanCreateSnapshotEvents);
} }
[Fact]
public void Should_handle_schema_event()
{
Assert.True(sut.Handles(new SchemaCreated()));
}
[Fact]
public void Should_not_handle_other_event()
{
Assert.False(sut.Handles(new AppCreated()));
}
[Theory] [Theory]
[MemberData(nameof(TestEvents))] [MemberData(nameof(TestEvents))]
public async Task Should_create_enriched_events(SchemaEvent @event, EnrichedSchemaEventType type) public async Task Should_create_enriched_events(SchemaEvent @event, EnrichedSchemaEventType type)
{ {
var ctx = Context();
var envelope = Envelope.Create<AppEvent>(@event).SetEventStreamNumber(12); var envelope = Envelope.Create<AppEvent>(@event).SetEventStreamNumber(12);
var result = await sut.CreateEnrichedEventsAsync(envelope); var result = await sut.CreateEnrichedEventsAsync(envelope, ctx, default).ToListAsync();
var enrichedEvent = result.Single() as EnrichedSchemaEvent; var enrichedEvent = result.Single() as EnrichedSchemaEvent;
Assert.Equal(type, enrichedEvent!.Type); Assert.Equal(type, enrichedEvent!.Type);
} }
[Fact]
public void Should_not_trigger_precheck_if_event_type_not_correct()
{
TestForCondition(string.Empty, trigger =>
{
var result = sut.Trigger(new AppCreated(), trigger, DomainId.NewGuid());
Assert.False(result);
});
}
[Fact] [Fact]
public void Should_trigger_precheck_if_event_type_correct() public void Should_trigger_precheck_if_event_type_correct()
{ {
TestForCondition(string.Empty, trigger => TestForCondition(string.Empty, ctx =>
{ {
var result = sut.Trigger(new SchemaCreated(), trigger, DomainId.NewGuid()); var @event = new SchemaCreated();
Assert.True(result); var result = sut.Trigger(Envelope.Create<AppEvent>(@event), ctx);
});
}
[Fact] Assert.True(result);
public void Should_not_trigger_check_if_event_type_not_correct()
{
TestForCondition(string.Empty, trigger =>
{
var result = sut.Trigger(new EnrichedContentEvent(), trigger);
Assert.False(result);
}); });
} }
[Fact] [Fact]
public void Should_trigger_check_if_condition_is_empty() public void Should_trigger_check_if_condition_is_empty()
{ {
TestForCondition(string.Empty, trigger => TestForCondition(string.Empty, ctx =>
{ {
var result = sut.Trigger(new EnrichedSchemaEvent(), trigger); var @event = new EnrichedSchemaEvent();
var result = sut.Trigger(@event, ctx);
Assert.True(result); Assert.True(result);
}); });
@ -114,30 +111,37 @@ namespace Squidex.Domain.Apps.Entities.Schemas
[Fact] [Fact]
public void Should_trigger_check_if_condition_matchs() public void Should_trigger_check_if_condition_matchs()
{ {
TestForCondition("true", trigger => TestForCondition("true", ctx =>
{ {
var result = sut.Trigger(new EnrichedSchemaEvent(), trigger); var @event = new EnrichedSchemaEvent();
var result = sut.Trigger(@event, ctx);
Assert.True(result); Assert.True(result);
}); });
} }
[Fact] [Fact]
public void Should_not_trigger_check_if_condition_does_not_matchs() public void Should_not_trigger_check_if_condition_does_not_match()
{ {
TestForCondition("false", trigger => TestForCondition("false", ctx =>
{ {
var result = sut.Trigger(new EnrichedSchemaEvent(), trigger); var @event = new EnrichedSchemaEvent();
var result = sut.Trigger(@event, ctx);
Assert.False(result); Assert.False(result);
}); });
} }
private void TestForCondition(string condition, Action<SchemaChangedTrigger> action) private void TestForCondition(string condition, Action<RuleContext> action)
{ {
var trigger = new SchemaChangedTrigger { Condition = condition }; var trigger = new SchemaChangedTrigger
{
Condition = condition
};
action(trigger); action(Context(trigger));
if (string.IsNullOrWhiteSpace(condition)) if (string.IsNullOrWhiteSpace(condition))
{ {
@ -150,5 +154,17 @@ namespace Squidex.Domain.Apps.Entities.Schemas
.MustHaveHappened(); .MustHaveHappened();
} }
} }
private static RuleContext Context(RuleTrigger? trigger = null)
{
trigger ??= new SchemaChangedTrigger();
return new RuleContext
{
AppId = NamedId.Of(DomainId.NewGuid(), "my-app"),
Rule = new Rule(trigger, A.Fake<RuleAction>()),
RuleId = DomainId.NewGuid()
};
}
} }
} }

1
backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj

@ -27,7 +27,6 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="Microsoft.Orleans.TestingHost" Version="3.4.2" /> <PackageReference Include="Microsoft.Orleans.TestingHost" Version="3.4.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Caching.Orleans" Version="1.8.0" /> <PackageReference Include="Squidex.Caching.Orleans" Version="1.8.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" /> <PackageReference Include="System.ValueTuple" Version="4.5.0" />

1
backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj

@ -16,7 +16,6 @@
<PackageReference Include="FakeItEasy" Version="7.0.1" /> <PackageReference Include="FakeItEasy" Version="7.0.1" />
<PackageReference Include="FluentAssertions" Version="5.10.3" /> <PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" /> <PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />

36
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreFixture.cs

@ -1,36 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Microsoft.Azure.Documents.Client;
using Squidex.Infrastructure.TestHelpers;
namespace Squidex.Infrastructure.EventSourcing
{
public sealed class CosmosDbEventStoreFixture : IDisposable
{
private const string EmulatorKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
private const string EmulatorUri = "https://localhost:8081";
private readonly DocumentClient client;
public CosmosDbEventStore EventStore { get; }
public CosmosDbEventStoreFixture()
{
client = new DocumentClient(new Uri(EmulatorUri), EmulatorKey, TestUtils.DefaultSettings());
EventStore = new CosmosDbEventStore(client, EmulatorKey, "Test", TestUtils.DefaultSerializer);
EventStore.InitializeAsync().Wait();
}
public void Dispose()
{
client.DeleteDatabaseAsync(UriFactory.CreateDatabaseUri("Test")).Wait();
client.Dispose();
}
}
}

31
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/CosmosDbEventStoreTests.cs

@ -1,31 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Xunit;
#pragma warning disable SA1300 // Element should begin with upper-case letter
namespace Squidex.Infrastructure.EventSourcing
{
[Trait("Category", "Dependencies")]
public class CosmosDbEventStoreTests : EventStoreTests<CosmosDbEventStore>, IClassFixture<CosmosDbEventStoreFixture>
{
public CosmosDbEventStoreFixture _ { get; }
protected override int SubscriptionDelayInMs { get; } = 1000;
public CosmosDbEventStoreTests(CosmosDbEventStoreFixture fixture)
{
_ = fixture;
}
public override CosmosDbEventStore CreateStore()
{
return _.EventStore;
}
}
}

112
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs

@ -99,7 +99,7 @@ namespace Squidex.Infrastructure.EventSourcing
await Sut.AppendAsync(Guid.NewGuid(), streamName, events); await Sut.AppendAsync(Guid.NewGuid(), streamName, events);
var readEvents1 = await QueryAsync(streamName); var readEvents1 = await QueryAsync(streamName);
var readEvents2 = await QueryWithCallbackAsync(streamName); var readEvents2 = await QueryAllAsync(streamName);
var expected = new[] var expected = new[]
{ {
@ -128,7 +128,7 @@ namespace Squidex.Infrastructure.EventSourcing
}); });
var readEvents1 = await QueryAsync(streamName); var readEvents1 = await QueryAsync(streamName);
var readEvents2 = await QueryWithCallbackAsync(streamName); var readEvents2 = await QueryAllAsync(streamName);
var expected = new[] var expected = new[]
{ {
@ -176,6 +176,7 @@ namespace Squidex.Infrastructure.EventSourcing
CreateEventData(2) CreateEventData(2)
}; };
// Append and read in parallel.
await QueryWithSubscriptionAsync(streamName, async () => await QueryWithSubscriptionAsync(streamName, async () =>
{ {
await Sut.AppendAsync(Guid.NewGuid(), streamName, events1); await Sut.AppendAsync(Guid.NewGuid(), streamName, events1);
@ -187,6 +188,7 @@ namespace Squidex.Infrastructure.EventSourcing
CreateEventData(2) CreateEventData(2)
}; };
// Append and read in parallel.
var readEventsFromPosition = await QueryWithSubscriptionAsync(streamName, async () => var readEventsFromPosition = await QueryWithSubscriptionAsync(streamName, async () =>
{ {
await Sut.AppendAsync(Guid.NewGuid(), streamName, events2); await Sut.AppendAsync(Guid.NewGuid(), streamName, events2);
@ -228,7 +230,7 @@ namespace Squidex.Infrastructure.EventSourcing
var firstRead = await QueryAsync(streamName); var firstRead = await QueryAsync(streamName);
var readEvents1 = await QueryAsync(streamName, 1); var readEvents1 = await QueryAsync(streamName, 1);
var readEvents2 = await QueryWithCallbackAsync(streamName, firstRead[0].EventPosition); var readEvents2 = await QueryAllAsync(streamName, firstRead[0].EventPosition);
var expected = new[] var expected = new[]
{ {
@ -279,9 +281,9 @@ namespace Squidex.Infrastructure.EventSourcing
} }
[Theory] [Theory]
[InlineData(30)] [InlineData(5, 30)]
[InlineData(1000)] [InlineData(5, 300)]
public async Task Should_read_latest_events(int count) public async Task Should_read_latest_events(int commitSize, int count)
{ {
var streamName = $"test-{Guid.NewGuid()}"; var streamName = $"test-{Guid.NewGuid()}";
@ -292,29 +294,60 @@ namespace Squidex.Infrastructure.EventSourcing
events.Add(CreateEventData(i)); events.Add(CreateEventData(i));
} }
for (var i = 0; i < events.Count / 2; i++) for (var i = 0; i < events.Count / commitSize; i++)
{ {
var commit = events.Skip(i * 2).Take(2); var commit = events.Skip(i * commitSize).Take(commitSize);
await Sut.AppendAsync(Guid.NewGuid(), streamName, commit.ToArray()); await Sut.AppendAsync(Guid.NewGuid(), streamName, commit.ToArray());
} }
var offset = 25; var allExpected = events.Select((x, i) => new StoredEvent(streamName, "Position", i, events[i])).ToArray();
var take = count - offset; var takeStep = count / 10;
var expected1 = events for (var take = 0; take < count; take += takeStep)
.Skip(offset) {
.Select((x, i) => new StoredEvent(streamName, "Position", i + offset, events[i + offset])) var expected = allExpected.TakeLast(take).ToArray();
.ToArray();
var readEvents = await Sut.QueryLatestAsync(streamName, take);
ShouldBeEquivalentTo(readEvents, expected);
}
}
[Theory]
[InlineData(5, 30)]
[InlineData(5, 300)]
public async Task Should_read_reverse(int commitSize, int count)
{
var streamName = $"test-{Guid.NewGuid()}";
var events = new List<EventData>();
for (var i = 0; i < count; i++)
{
events.Add(CreateEventData(i));
}
for (var i = 0; i < events.Count / commitSize; i++)
{
var commit = events.Skip(i * commitSize).Take(commitSize);
await Sut.AppendAsync(Guid.NewGuid(), streamName, commit.ToArray());
}
var allExpected = events.Select((x, i) => new StoredEvent(streamName, "Position", i, events[i])).ToArray();
var expected2 = Array.Empty<StoredEvent>(); var takeStep = count / 10;
for (var take = 0; take < count; take += takeStep)
{
var expected = allExpected.Reverse().Take(take).ToArray();
var readEvents1 = await Sut.QueryLatestAsync(streamName, take); var readEvents = await Sut.QueryAllReverseAsync(streamName, null, take).ToArrayAsync();
var readEvents2 = await Sut.QueryLatestAsync(streamName, 0);
ShouldBeEquivalentTo(readEvents1, expected1); ShouldBeEquivalentTo(readEvents, expected);
ShouldBeEquivalentTo(readEvents2, expected2); }
} }
[Fact] [Fact]
@ -347,36 +380,19 @@ namespace Squidex.Infrastructure.EventSourcing
return new EventData($"Type{i}", new EnvelopeHeaders(), i.ToString()); return new EventData($"Type{i}", new EnvelopeHeaders(), i.ToString());
} }
private async Task<IReadOnlyList<StoredEvent>?> QueryWithCallbackAsync(string? streamFilter = null, string? position = null) private async Task<IReadOnlyList<StoredEvent>?> QueryAllAsync(string? streamFilter = null, string? position = null)
{ {
using (var cts = new CancellationTokenSource(30000)) var readEvents = new List<StoredEvent>();
{
while (!cts.IsCancellationRequested)
{
var readEvents = new List<StoredEvent>();
await Sut.QueryAsync(x =>
{
readEvents.Add(x);
return Task.CompletedTask; await foreach (var storedEvent in Sut.QueryAllAsync(streamFilter, position))
}, streamFilter, position, cts.Token); {
readEvents.Add(storedEvent);
await Task.Delay(500, cts.Token);
if (readEvents.Count > 0)
{
return readEvents;
}
}
cts.Token.ThrowIfCancellationRequested();
return null;
} }
return readEvents;
} }
private async Task<IReadOnlyList<StoredEvent>?> QueryWithSubscriptionAsync(string streamFilter, Func<Task>? action = null, bool fromBeginning = false) private async Task<IReadOnlyList<StoredEvent>?> QueryWithSubscriptionAsync(string streamFilter, Func<Task>? subscriptionRunning = null, bool fromBeginning = false)
{ {
var subscriber = new EventSubscriber(); var subscriber = new EventSubscriber();
@ -385,9 +401,9 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
subscription = Sut.CreateSubscription(subscriber, streamFilter, fromBeginning ? null : subscriptionPosition); subscription = Sut.CreateSubscription(subscriber, streamFilter, fromBeginning ? null : subscriptionPosition);
if (action != null) if (subscriptionRunning != null)
{ {
await action(); await subscriptionRunning();
} }
using (var cts = new CancellationTokenSource(30000)) using (var cts = new CancellationTokenSource(30000))
@ -396,7 +412,7 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
subscription.WakeUp(); subscription.WakeUp();
await Task.Delay(500, cts.Token); await Task.Delay(2000, cts.Token);
if (subscriber.Events.Count > 0) if (subscriber.Events.Count > 0)
{ {
@ -419,7 +435,7 @@ namespace Squidex.Infrastructure.EventSourcing
private static void ShouldBeEquivalentTo(IEnumerable<StoredEvent>? actual, params StoredEvent[] expected) private static void ShouldBeEquivalentTo(IEnumerable<StoredEvent>? actual, params StoredEvent[] expected)
{ {
actual.Should().BeEquivalentTo(expected, opts => opts.Excluding(x => x.EventPosition)); actual.Should().BeEquivalentTo(expected, opts => opts.ComparingByMembers<StoredEvent>().Including(x => x.EventStreamNumber));
} }
} }
} }

64
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreFixture.cs

@ -1,64 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using EventStore.ClientAPI;
using EventStore.ClientAPI.Projections;
using Squidex.Infrastructure.TestHelpers;
namespace Squidex.Infrastructure.EventSourcing
{
public sealed class GetEventStoreFixture : IDisposable
{
private readonly IEventStoreConnection connection;
public GetEventStore EventStore { get; }
public GetEventStoreFixture()
{
connection = EventStoreConnection.Create("ConnectTo=tcp://admin:changeit@localhost:1113; HeartBeatTimeout=500; MaxReconnections=-1");
EventStore = new GetEventStore(connection, TestUtils.DefaultSerializer, "test", "localhost");
EventStore.InitializeAsync().Wait();
}
public void Dispose()
{
CleanupAsync().Wait();
connection.Dispose();
}
private async Task CleanupAsync()
{
var endpoints = await Dns.GetHostAddressesAsync("localhost");
var endpoint = new IPEndPoint(endpoints.First(x => x.AddressFamily == AddressFamily.InterNetwork), 2113);
var credentials = connection.Settings.DefaultUserCredentials;
var projectionsManager =
new ProjectionsManager(
connection.Settings.Log, endpoint,
connection.Settings.OperationTimeout);
foreach (var projection in await projectionsManager.ListAllAsync(credentials))
{
var name = projection.Name;
if (name.StartsWith("by-test", StringComparison.OrdinalIgnoreCase))
{
await projectionsManager.DisableAsync(name, credentials);
await projectionsManager.DeleteAsync(name, true, credentials);
}
}
}
}
}

31
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/GetEventStoreTests.cs

@ -1,31 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Xunit;
#pragma warning disable SA1300 // Element should begin with upper-case letter
namespace Squidex.Infrastructure.EventSourcing
{
[Trait("Category", "Dependencies")]
public class GetEventStoreTests : EventStoreTests<GetEventStore>, IClassFixture<GetEventStoreFixture>
{
public GetEventStoreFixture _ { get; }
protected override int SubscriptionDelayInMs { get; } = 1000;
public GetEventStoreTests(GetEventStoreFixture fixture)
{
_ = fixture;
}
public override GetEventStore CreateStore()
{
return _.EventStore;
}
}
}

10
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/PollingSubscriptionTests.cs

@ -26,7 +26,7 @@ namespace Squidex.Infrastructure.EventSourcing
await WaitAndStopAsync(sut); await WaitAndStopAsync(sut);
A.CallTo(() => eventStore.QueryAsync(A<Func<StoredEvent, Task>>._, "^my-stream", position, A<CancellationToken>._)) A.CallTo(() => eventStore.QueryAllAsync("^my-stream", position, A<long>._, A<CancellationToken>._))
.MustHaveHappenedOnceExactly(); .MustHaveHappenedOnceExactly();
} }
@ -35,7 +35,7 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
var ex = new InvalidOperationException(); var ex = new InvalidOperationException();
A.CallTo(() => eventStore.QueryAsync(A<Func<StoredEvent, Task>>._, "^my-stream", position, A<CancellationToken>._)) A.CallTo(() => eventStore.QueryAllAsync("^my-stream", position, A<long>._, A<CancellationToken>._))
.Throws(ex); .Throws(ex);
var sut = new PollingSubscription(eventStore, eventSubscriber, "^my-stream", position); var sut = new PollingSubscription(eventStore, eventSubscriber, "^my-stream", position);
@ -51,7 +51,7 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
var ex = new OperationCanceledException(); var ex = new OperationCanceledException();
A.CallTo(() => eventStore.QueryAsync(A<Func<StoredEvent, Task>>._, "^my-stream", position, A<CancellationToken>._)) A.CallTo(() => eventStore.QueryAllAsync("^my-stream", position, A<long>._, A<CancellationToken>._))
.Throws(ex); .Throws(ex);
var sut = new PollingSubscription(eventStore, eventSubscriber, "^my-stream", position); var sut = new PollingSubscription(eventStore, eventSubscriber, "^my-stream", position);
@ -67,7 +67,7 @@ namespace Squidex.Infrastructure.EventSourcing
{ {
var ex = new AggregateException(new OperationCanceledException()); var ex = new AggregateException(new OperationCanceledException());
A.CallTo(() => eventStore.QueryAsync(A<Func<StoredEvent, Task>>._, "^my-stream", position, A<CancellationToken>._)) A.CallTo(() => eventStore.QueryAllAsync("^my-stream", position, A<long>._, A<CancellationToken>._))
.Throws(ex); .Throws(ex);
var sut = new PollingSubscription(eventStore, eventSubscriber, "^my-stream", position); var sut = new PollingSubscription(eventStore, eventSubscriber, "^my-stream", position);
@ -87,7 +87,7 @@ namespace Squidex.Infrastructure.EventSourcing
await WaitAndStopAsync(sut); await WaitAndStopAsync(sut);
A.CallTo(() => eventStore.QueryAsync(A<Func<StoredEvent, Task>>._, "^my-stream", position, A<CancellationToken>._)) A.CallTo(() => eventStore.QueryAllAsync("^my-stream", position, A<long>._, A<CancellationToken>._))
.MustHaveHappened(2, Times.Exactly); .MustHaveHappened(2, Times.Exactly);
} }

3
backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj

@ -8,8 +8,6 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\Squidex.Infrastructure.Azure\Squidex.Infrastructure.Azure.csproj" />
<ProjectReference Include="..\..\src\Squidex.Infrastructure.GetEventStore\Squidex.Infrastructure.GetEventStore.csproj" />
<ProjectReference Include="..\..\src\Squidex.Infrastructure.MongoDb\Squidex.Infrastructure.MongoDb.csproj" /> <ProjectReference Include="..\..\src\Squidex.Infrastructure.MongoDb\Squidex.Infrastructure.MongoDb.csproj" />
<ProjectReference Include="..\..\src\Squidex.Infrastructure\Squidex.Infrastructure.csproj" /> <ProjectReference Include="..\..\src\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
@ -25,7 +23,6 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" /> <PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />

1
backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj

@ -15,7 +15,6 @@
<PackageReference Include="IdentityServer4" Version="4.1.2" /> <PackageReference Include="IdentityServer4" Version="4.1.2" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="4.1.2" /> <PackageReference Include="IdentityServer4.AspNetIdentity" Version="4.1.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">

1
backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj

@ -5,7 +5,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">

1
backend/tools/TestSuite/TestSuite.LoadTests/TestSuite.LoadTests.csproj

@ -5,7 +5,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">

6
frontend/app/features/administration/pages/users/users-page.component.html

@ -36,13 +36,13 @@
&nbsp; &nbsp;
</th> </th>
<th class="cell-auto"> <th class="cell-auto">
{{ 'common.name' | sqxTranslate }} <span class="truncate">{{ 'common.name' | sqxTranslate }}</span>
</th> </th>
<th class="cell-auto"> <th class="cell-auto">
{{ 'common.email' | sqxTranslate }} <span class="truncate">{{ 'common.email' | sqxTranslate }}</span>
</th> </th>
<th class="cell-actions-lg"> <th class="cell-actions-lg">
{{ 'common.actions' | sqxTranslate }} <span class="truncate">{{ 'common.actions' | sqxTranslate }}</span>
</th> </th>
</tr> </tr>
</thead> </thead>

2
frontend/app/features/apps/pages/apps-page.component.html

@ -21,7 +21,7 @@
</div> </div>
</ng-container> </ng-container>
<div class="apps-section" *ngIf="(uiState.settings | async).canCreateApps"> <div class="apps-section" *ngIf="(uiState.settings | async)?.canCreateApps">
<div class="card card-template card-href" (click)="createNewApp('')"> <div class="card card-template card-href" (click)="createNewApp('')">
<div class="card-body"> <div class="card-body">
<div class="card-image"> <div class="card-image">

2
frontend/app/features/content/pages/content/content-history-page.component.html

@ -123,7 +123,7 @@
</div> </div>
<div class="section"> <div class="section">
<h3 class="bordered">{{ 'common.history2' | sqxTranslate }}</h3> <h3 class="bordered">{{ 'common.history' | sqxTranslate }}</h3>
<sqx-content-event *ngFor="let event of contentEvents | async; trackBy: trackByEvent" <sqx-content-event *ngFor="let event of contentEvents | async; trackBy: trackByEvent"
[content]="content" [content]="content"

5
frontend/app/features/content/shared/forms/assets-editor.component.ts

@ -49,6 +49,11 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
@Input() @Input()
public folderId?: string; public folderId?: string;
@Input()
public set disabled(value: boolean | null | undefined) {
this.setDisabledState(value === true);
}
public assetsDialog = new DialogModel(); public assetsDialog = new DialogModel();
constructor(changeDetector: ChangeDetectorRef, constructor(changeDetector: ChangeDetectorRef,

9
frontend/app/features/content/shared/forms/iframe-editor.component.ts

@ -59,6 +59,11 @@ export class IFrameEditorComponent extends StatefulControlComponent<State, any>
@Input() @Input()
public url: string; public url: string;
@Input()
public set disabled(value: boolean | null | undefined) {
this.setDisabledState(value === true);
}
public assetsDialog = new DialogModel(); public assetsDialog = new DialogModel();
public fullscreen: boolean; public fullscreen: boolean;
@ -198,9 +203,7 @@ export class IFrameEditorComponent extends StatefulControlComponent<State, any>
this.sendValue(); this.sendValue();
} }
public setDisabledState(isDisabled: boolean): void { public onDisabled() {
super.setDisabledState(isDisabled);
this.sendDisabled(); this.sendDisabled();
} }

11
frontend/app/features/content/shared/forms/stock-photo-editor.component.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, OnInit } from '@angular/core'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core';
import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { StatefulControlComponent, StockPhotoDto, StockPhotoService, thumbnail, Types, value$ } from '@app/shared'; import { StatefulControlComponent, StockPhotoDto, StockPhotoService, thumbnail, Types, value$ } from '@app/shared';
import { of } from 'rxjs'; import { of } from 'rxjs';
@ -33,6 +33,11 @@ interface State {
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class StockPhotoEditorComponent extends StatefulControlComponent<State, string> implements OnInit { export class StockPhotoEditorComponent extends StatefulControlComponent<State, string> implements OnInit {
@Input()
public set disabled(value: boolean | null | undefined) {
this.setDisabledState(value === true);
}
public valueControl = new FormControl(''); public valueControl = new FormControl('');
public stockPhotoThumbnail = value$(this.valueControl).pipe(map(v => thumbnail(v, 400) || v)); public stockPhotoThumbnail = value$(this.valueControl).pipe(map(v => thumbnail(v, 400) || v));
@ -80,9 +85,7 @@ export class StockPhotoEditorComponent extends StatefulControlComponent<State, s
} }
} }
public setDisabledState(isDisabled: boolean): void { public onDisabled(isDisabled: boolean) {
super.setDisabledState(isDisabled);
if (isDisabled) { if (isDisabled) {
this.stockPhotoSearch.disable({ emitEvent: false }); this.stockPhotoSearch.disable({ emitEvent: false });
} else { } else {

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save