Browse Source

Cron jobs (#1220)

* Flow fixes.

* Temp

* Remove duplicate timers.

* Add dump.

* Fix styles and UI bugs.

* More and fixed tests

* Fix tests

* Fix e2e tests
pull/1221/head
Sebastian Stehle 1 year ago
committed by GitHub
parent
commit
bfa0c36ccf
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentFlowStep.cs
  2. 1
      backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectFlowStep.cs
  3. 23
      backend/extensions/Squidex.Extensions/Actions/Discourse/DiscourseFlowStep.cs
  4. 14
      backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyFlowStep.cs
  5. 22
      backend/extensions/Squidex.Extensions/Actions/Medium/MediumFlowStep.cs
  6. 15
      backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderFlowStep.cs
  7. 49
      backend/extensions/Squidex.Extensions/Actions/RuleHelper.cs
  8. 15
      backend/extensions/Squidex.Extensions/Actions/Slack/SlackFlowStep.cs
  9. 15
      backend/extensions/Squidex.Extensions/Actions/Typesense/TypesenseFlowStep.cs
  10. 29
      backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookFlowStep.cs
  11. 13
      backend/i18n/frontend_en.json
  12. 11
      backend/i18n/frontend_fr.json
  13. 13
      backend/i18n/frontend_it.json
  14. 11
      backend/i18n/frontend_nl.json
  15. 11
      backend/i18n/frontend_pt.json
  16. 13
      backend/i18n/frontend_zh.json
  17. 4
      backend/i18n/source/backend_en.json
  18. 2
      backend/i18n/source/backend_fr.json
  19. 2
      backend/i18n/source/backend_it.json
  20. 2
      backend/i18n/source/backend_nl.json
  21. 2
      backend/i18n/source/backend_pt.json
  22. 13
      backend/i18n/source/frontend_en.json
  23. 3
      backend/src/Squidex.Data.EntityFramework/AppDbContext.cs
  24. 3
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20250504173108_AddFlows.cs
  25. 1586
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20250513192106_AddCronJobs.Designer.cs
  26. 43
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20250513192106_AddCronJobs.cs
  27. 20
      backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/MySqlDbContextModelSnapshot.cs
  28. 3
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20250504173116_AddFlows.cs
  29. 1587
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20250513192113_AddCronJobs.Designer.cs
  30. 40
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20250513192113_AddCronJobs.cs
  31. 20
      backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/PostgresDbContextModelSnapshot.cs
  32. 3
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20250504173123_AddFlows.cs
  33. 1589
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20250513192120_AddCronJobs.Designer.cs
  34. 40
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20250513192120_AddCronJobs.cs
  35. 20
      backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/SqlServerDbContextModelSnapshot.cs
  36. 4
      backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs
  37. 14
      backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj
  38. 6
      backend/src/Squidex.Data.MongoDb/ServiceExtensions.cs
  39. 12
      backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj
  40. 20
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedCronJobEvent.cs
  41. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleTriggerVisitor.cs
  42. 26
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/CronJobTrigger.cs
  43. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
  44. 14
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/CronJobContext.cs
  45. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  46. 44
      backend/src/Squidex.Domain.Apps.Entities/Rules/CronJobTriggerHandler.cs
  47. 90
      backend/src/Squidex.Domain.Apps.Entities/Rules/CronJobUpdater.cs
  48. 3
      backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/GuardRule.cs
  49. 1
      backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.State.cs
  50. 4
      backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/RuleDomainObject.cs
  51. 3
      backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleEnqueuer.cs
  52. 15
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
  53. 26
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleJobUpdate.cs
  54. 27
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleValidator.cs
  55. 96
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs
  56. 3
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs
  57. 12
      backend/src/Squidex.Domain.Apps.Events/Rules/RuleCronJobTriggered.cs
  58. 2
      backend/src/Squidex.Infrastructure/Commands/DomainObject.cs
  59. 105
      backend/src/Squidex.Infrastructure/Http/DumpFormatter.cs
  60. 1
      backend/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs
  61. 14
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  62. 12
      backend/src/Squidex.Shared/Texts.fr.resx
  63. 12
      backend/src/Squidex.Shared/Texts.it.resx
  64. 12
      backend/src/Squidex.Shared/Texts.nl.resx
  65. 12
      backend/src/Squidex.Shared/Texts.pt.resx
  66. 12
      backend/src/Squidex.Shared/Texts.resx
  67. 12
      backend/src/Squidex.Shared/Texts.zh.resx
  68. 1
      backend/src/Squidex/Areas/Api/Config/OpenApi/DiscriminatorProcessor.cs
  69. 11
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  70. 5
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs
  71. 10
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/CreateRuleDto.cs
  72. 1
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/DynamicUpdateRuleDto.cs
  73. 43
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/CronJobRuleTriggerDto.cs
  74. 34
      backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  75. 27
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs
  76. 10
      backend/src/Squidex/Config/Domain/RuleServices.cs
  77. 4
      backend/src/Squidex/Config/Messaging/MessagingServices.cs
  78. 24
      backend/src/Squidex/Squidex.csproj
  79. 1
      backend/tests/Squidex.Data.Tests.CodeGenerator/CodeGenerator.cs
  80. 1
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/EventJsonSchemaGeneratorTests.cs
  81. 79
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/CronJobTriggerHandlerTests.cs
  82. 221
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/CronJobUpdaterTests.cs
  83. 10
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/GuardRuleTests.cs
  84. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/ContentChangedTriggerTests.cs
  85. 89
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/CronJobTriggerValidationTests.cs
  86. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/UsageTriggerValidationTests.cs
  87. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/RuleDomainObjectTests.cs
  88. 22
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs
  89. 80
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleFlowTrackingCallbackTests.cs
  90. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleValidatorTests.cs
  91. 9
      backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/GivenContext.cs
  92. 127
      backend/tests/Squidex.Infrastructure.Tests/Http/DumpFormatterTests.cs
  93. 6
      frontend/src/app/features/administration/pages/users/user-page.component.html
  94. 96
      frontend/src/app/features/rules/pages/events/rule-event.stories.ts
  95. 18
      frontend/src/app/features/rules/pages/rule/step-dialog.component.html
  96. 15
      frontend/src/app/features/rules/pages/rule/trigger-dialog.component.html
  97. 2
      frontend/src/app/features/rules/pages/rule/trigger-dialog.component.ts
  98. 8
      frontend/src/app/features/rules/pages/rules/rule.component.html
  99. 4
      frontend/src/app/features/rules/pages/simulator/simulated-rule-event.component.html
  100. 2
      frontend/src/app/features/rules/pages/simulator/simulated-rule-event.stories.ts

1
backend/extensions/Squidex.Extensions/Actions/CreateContent/CreateContentFlowStep.cs

@ -15,7 +15,6 @@ using Squidex.Domain.Apps.Entities;
using Squidex.Flows;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Json;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
using Command = Squidex.Domain.Apps.Entities.Contents.Commands.CreateContent;

1
backend/extensions/Squidex.Extensions/Actions/DeepDetect/DeepDetectFlowStep.cs

@ -8,7 +8,6 @@
using System.ComponentModel.DataAnnotations;
using System.Net.Http.Json;
using System.Text.RegularExpressions;
using Google.Apis.Json;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.HandleRules;

23
backend/extensions/Squidex.Extensions/Actions/Discourse/DiscourseFlowStep.cs

@ -10,7 +10,7 @@ using System.Text;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Extensions.Actions.Algolia;
using Squidex.Flows;
using Squidex.Infrastructure.Json;
using Squidex.Flows.Steps.Utils;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
@ -65,12 +65,6 @@ public sealed record DiscourseFlowStep : FlowStep, IConvertibleToAction
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var url = $"{Url.ToString().TrimEnd('/')}/posts.json?api_key={ApiKey}&api_username={ApiUsername}";
var body = new Dictionary<string, object?>
@ -92,10 +86,6 @@ public sealed record DiscourseFlowStep : FlowStep, IConvertibleToAction
var jsonRequest = executionContext.SerializeJson(body);
var httpClient =
executionContext.Resolve<IHttpClientFactory>()
.CreateClient("DiscourseAction");
var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(jsonRequest, Encoding.UTF8, "application/json"),
@ -104,6 +94,17 @@ public sealed record DiscourseFlowStep : FlowStep, IConvertibleToAction
request.Headers.TryAddWithoutValidation("Api-Key", ApiKey);
request.Headers.TryAddWithoutValidation("Api-Username", ApiUsername);
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation(
HttpDumpFormatter.BuildDump(request, null, null));
return Next();
}
var httpClient =
executionContext.Resolve<IHttpClientFactory>()
.CreateClient("DiscourseAction");
var (_, dump) = await httpClient.SendAsync(executionContext, request, jsonRequest, ct);
if (Topic != null)

14
backend/extensions/Squidex.Extensions/Actions/Fastly/FastlyFlowStep.cs

@ -10,6 +10,7 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Flows;
using Squidex.Flows.Steps.Utils;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
@ -40,12 +41,6 @@ public sealed record FastlyFlowStep : FlowStep, IConvertibleToAction
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var @event = ((FlowEventContext)executionContext.Context).Event;
var id = string.Empty;
@ -61,6 +56,13 @@ public sealed record FastlyFlowStep : FlowStep, IConvertibleToAction
var requestUrl = $"/service/{ServiceId}/purge/{id}";
var request = new HttpRequestMessage(HttpMethod.Post, requestUrl);
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation(
HttpDumpFormatter.BuildDump(request, null, null));
return Next();
}
request.Headers.Add("Fastly-Key", ApiKey);
var (_, dump) = await httpClient.SendAsync(executionContext, request, ct: ct);

22
backend/extensions/Squidex.Extensions/Actions/Medium/MediumFlowStep.cs

@ -9,7 +9,7 @@ using System.ComponentModel.DataAnnotations;
using System.Text;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Json;
using Squidex.Flows.Steps.Utils;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
@ -64,12 +64,6 @@ public sealed record MediumFlowStep : FlowStep, IConvertibleToAction
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var httpClient =
executionContext.Resolve<IHttpClientFactory>()
.CreateClient("MediumAction");
@ -79,6 +73,10 @@ public sealed record MediumFlowStep : FlowStep, IConvertibleToAction
{
path = $"/v1/publications/{PublicationId}/posts";
}
else if (executionContext.IsSimulation)
{
path = "/v1/users/simulated-id/posts";
}
else
{
var meRequest = BuildGetRequest("/v1/me");
@ -101,8 +99,16 @@ public sealed record MediumFlowStep : FlowStep, IConvertibleToAction
};
var requestJson = executionContext.SerializeJson(requestBody);
var requestMessage = BuildPostRequest(path, requestJson);
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation(
HttpDumpFormatter.BuildDump(requestMessage, null, null));
return Next();
}
var (_, dump) = await httpClient.SendAsync(executionContext, BuildPostRequest(path, requestJson), requestJson, ct);
var (_, dump) = await httpClient.SendAsync(executionContext, requestMessage, requestJson, ct);
executionContext.Log("Post created", dump);
return Next();

15
backend/extensions/Squidex.Extensions/Actions/Prerender/PrerenderFlowStep.cs

@ -9,7 +9,7 @@ using System.ComponentModel.DataAnnotations;
using System.Text;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Json;
using Squidex.Flows.Steps.Utils;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
@ -41,12 +41,6 @@ public sealed record PrerenderFlowStep : FlowStep, IConvertibleToAction
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var requestObject = new { prerenderToken = Token, Url };
var requestBody = executionContext.SerializeJson(requestObject);
@ -55,6 +49,13 @@ public sealed record PrerenderFlowStep : FlowStep, IConvertibleToAction
Content = new StringContent(requestBody, Encoding.UTF8, "application/json"),
};
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation(
HttpDumpFormatter.BuildDump(request, null, null));
return Next();
}
var httpClient =
executionContext.Resolve<IHttpClientFactory>()
.CreateClient("Prerender");

49
backend/extensions/Squidex.Extensions/Actions/RuleHelper.cs

@ -6,31 +6,13 @@
// ==========================================================================
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Flows;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Http;
namespace Squidex.Extensions.Actions;
public static class RuleHelper
{
public static bool ShouldDelete(this EnrichedEvent @event, IScriptEngine scriptEngine, string? expression)
{
if (!string.IsNullOrWhiteSpace(expression))
{
// Script vars are just wrappers over dictionaries for better performance.
var vars = new EventScriptVars
{
Event = @event,
};
return scriptEngine.Evaluate(vars, expression);
}
return IsContentDeletion(@event) || IsAssetDeletion(@event);
}
public static bool ShouldDelete(this EnrichedEvent @event, FlowExecutionContext context, string? expression)
{
if (!string.IsNullOrWhiteSpace(expression))
@ -51,37 +33,6 @@ public static class RuleHelper
return @event is EnrichedAssetEvent { Type: EnrichedAssetEventType.Deleted };
}
public static async Task<(string Response, string Dump)> SendAsync(this HttpClient client,
FlowExecutionContext executionContext,
HttpRequestMessage request,
string? requestBody = null,
CancellationToken ct = default)
{
HttpResponseMessage? response = null;
try
{
response = await client.SendAsync(request, ct);
var responseString = await response.Content.ReadAsStringAsync(ct);
var requestDump = DumpFormatter.BuildDump(request, response, requestBody, responseString);
if (!response.IsSuccessStatusCode)
{
executionContext.Log("Http request failed", requestDump);
throw new HttpRequestException($"Response code does not indicate success: {(int)response.StatusCode} ({response.StatusCode}).");
}
return (responseString, requestDump);
}
catch (Exception ex)
{
var requestDump = DumpFormatter.BuildDump(request, response, requestBody, ex.ToString());
executionContext.Log("Http request failed", requestDump);
throw;
}
}
public static (string Id, bool IsGenerated) GetOrCreateId(this EnrichedEvent @event)
{
if (@event is IEnrichedEntityEvent enrichedEntityEvent)

15
backend/extensions/Squidex.Extensions/Actions/Slack/SlackFlowStep.cs

@ -7,10 +7,10 @@
using System.ComponentModel.DataAnnotations;
using System.Text;
using Google.Apis.Json;
using Migrations.OldActions;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Flows.Steps.Utils;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
@ -42,12 +42,6 @@ public sealed record SlackFlowStep : FlowStep, IConvertibleToAction
public override async ValueTask<FlowStepResult> ExecuteAsync(FlowExecutionContext executionContext,
CancellationToken ct)
{
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var body = new { text = Text };
var jsonRequest = executionContext.SerializeJson(body);
@ -57,6 +51,13 @@ public sealed record SlackFlowStep : FlowStep, IConvertibleToAction
Content = new StringContent(jsonRequest, Encoding.UTF8, "application/json"),
};
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation(
HttpDumpFormatter.BuildDump(request, null, null));
return Next();
}
var httpClient =
executionContext.Resolve<IHttpClientFactory>()
.CreateClient("SlackAction");

15
backend/extensions/Squidex.Extensions/Actions/Typesense/TypesenseFlowStep.cs

@ -11,7 +11,7 @@ using System.Text.Json.Serialization;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Infrastructure.Json;
using Squidex.Flows.Steps.Utils;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
@ -100,16 +100,17 @@ public sealed record TypesenseFlowStep : FlowStep, IConvertibleToAction
return Next();
}
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
async Task SendAsync(HttpRequestMessage request, string? body, string message)
{
request.Headers.TryAddWithoutValidation("X-Typesense-Api-Key", ApiKey);
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation(
HttpDumpFormatter.BuildDump(request, null, null));
return;
}
var httpClient =
executionContext.Resolve<IHttpClientFactory>()
.CreateClient("TypesenseAction");

29
backend/extensions/Squidex.Extensions/Actions/Webhook/WebhookFlowStep.cs

@ -9,7 +9,7 @@ using System.ComponentModel.DataAnnotations;
using System.Text;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Flows;
using Squidex.Flows.Steps;
using Squidex.Flows.Steps.Utils;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Validation;
@ -74,13 +74,7 @@ public sealed record WebhookFlowStep : FlowStep, IConvertibleToAction
break;
}
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation();
return Next();
}
var httpClient = executionContext.Resolve<IHttpClientFactory>().CreateClient("FlowClient");
string? requestBody = null;
var request = new HttpRequestMessage(method, Url);
if (!string.IsNullOrEmpty(Payload) && Method != WebhookMethod.GET)
@ -91,6 +85,7 @@ public sealed record WebhookFlowStep : FlowStep, IConvertibleToAction
mediaType = "application/json";
}
requestBody = Payload;
request.Content = new StringContent(Payload, Encoding.UTF8, mediaType);
}
@ -103,11 +98,23 @@ public sealed record WebhookFlowStep : FlowStep, IConvertibleToAction
}
}
var signature = $"{Payload}{SharedSecret}".ToSha256Base64();
if (!string.IsNullOrWhiteSpace(SharedSecret))
{
var signature = $"{Payload}{SharedSecret}".ToSha256Base64();
request.Headers.Add("X-Signature", signature);
request.Headers.Add("X-Signature", signature);
}
if (executionContext.IsSimulation)
{
executionContext.LogSkipSimulation(
HttpDumpFormatter.BuildDump(request, null, requestBody, null));
return Next();
}
var httpClient = executionContext.Resolve<IHttpClientFactory>().CreateClient("FlowClient");
var (_, dump) = await httpClient.SendAsync(executionContext, request, Payload, ct);
var (_, dump) = await httpClient.SendAsync(executionContext, request, requestBody, ct);
executionContext.Log("HTTP request sent", dump);
return Next();

13
backend/i18n/frontend_en.json

@ -336,6 +336,7 @@
"common.prevPage": "Previous Page",
"common.product": "Squidex Headless CMS",
"common.project": "Project",
"common.properties": "Properties",
"common.queryOperators.contains": "contains",
"common.queryOperators.empty": "is empty",
"common.queryOperators.endsWith": "ends with",
@ -714,6 +715,15 @@
"rules.conditions.commentKeyword": "Only for text keywords",
"rules.conditions.commentUser": "Specific users",
"rules.conditions.contentValue": "Content has value",
"rules.conditions.cronExpression": "Cron Expression",
"rules.conditions.cronExpressionEvery4Hours": "Every 4 hours",
"rules.conditions.cronExpressionEveryMonth": "First day every month, at 6 AM in the morning",
"rules.conditions.cronExpressionEveryMorning": "Every day at 6 AM in the morning",
"rules.conditions.cronExpressionHint": "A cron expression is a string consisting of six or seven subexpressions (fields) that describe individual details of the schedule. You cannot create expressions that trigger this flow more often than every 4 hours.",
"rules.conditions.cronExpressionsHint": "Typical cron expressions",
"rules.conditions.cronExpressionsTitle": "Cron Expressions",
"rules.conditions.cronTimezone": "Timezone",
"rules.conditions.cronTimezoneHint": "The timezone as IANA format to define how to calculate the next time the flow should be run. Use your local timezone if a job should run every morning before working hours.",
"rules.conditions.event": "Specific events",
"rules.conditions.images": "Images only",
"rules.conditions.largeAssets": "Large assets",
@ -761,6 +771,7 @@
"rules.ruleEvents.loadFailed": "Failed to load events. Please reload.",
"rules.ruleEvents.nextAttemptLabel": "Next",
"rules.ruleEvents.reloaded": "RuleEvents reloaded.",
"rules.ruleEvents.validationFailed": "Failed to validate rule.",
"rules.ruleSimulator.listPageTitle": "Simulator",
"rules.ruleSyntax.if": "when",
"rules.ruleSyntax.then": "then",
@ -783,7 +794,7 @@
"rules.simulation.errorTooOld": "STOP: Event is too old.",
"rules.simulation.errorWrongEvent": "STOP: Event does not match to the trigger.",
"rules.simulation.errorWrongEventForTrigger": "STOP: Event does not match to the trigger.",
"rules.simulation.eventConditionEvaluated": "Enriched event is evaluated, whether it matchs to the conditions and javascript expressions in the trigger.",
"rules.simulation.eventConditionEvaluated": "Enriched is tested against the conditions and javascript expressions in the trigger.",
"rules.simulation.eventEnriched": "Event is enriched with additional data",
"rules.simulation.eventQueried": "Event is queried from the database",
"rules.simulation.eventTriggerChecked": "Event is tested to see if it matchs to the trigger and the basic conditions.",

11
backend/i18n/frontend_fr.json

@ -336,6 +336,7 @@
"common.prevPage": "Previous Page",
"common.product": "CMS sans tête Squidex",
"common.project": "Projet",
"common.properties": "Properties",
"common.queryOperators.contains": "contient",
"common.queryOperators.empty": "est vide",
"common.queryOperators.endsWith": "se termine par",
@ -714,6 +715,15 @@
"rules.conditions.commentKeyword": "Uniquement pour les mots-clés textuels",
"rules.conditions.commentUser": "Utilisateurs spécifiques",
"rules.conditions.contentValue": "Le contenu a de la valeur",
"rules.conditions.cronExpression": "Cron Expression",
"rules.conditions.cronExpressionEvery4Hours": "Every 4 hours",
"rules.conditions.cronExpressionEveryMonth": "First day every month, at 6 AM in the morning",
"rules.conditions.cronExpressionEveryMorning": "Every day at 6 AM in the morning",
"rules.conditions.cronExpressionHint": "A cron expression is a string consisting of six or seven subexpressions (fields) that describe individual details of the schedule. You cannot create expressions that trigger this flow more often than every 4 hours.",
"rules.conditions.cronExpressionsHint": "Typical cron expressions",
"rules.conditions.cronExpressionsTitle": "Cron Expressions",
"rules.conditions.cronTimezone": "Timezone",
"rules.conditions.cronTimezoneHint": "The timezone as IANA format to define how to calculate the next time the flow should be run. Use your local timezone if a job should run every morning before working hours.",
"rules.conditions.event": "Événements spécifiques",
"rules.conditions.images": "Images uniquement",
"rules.conditions.largeAssets": "Grands atouts",
@ -761,6 +771,7 @@
"rules.ruleEvents.loadFailed": "Échec du chargement des événements. Veuillez recharger.",
"rules.ruleEvents.nextAttemptLabel": "Suivant",
"rules.ruleEvents.reloaded": "RuleEvents rechargé.",
"rules.ruleEvents.validationFailed": "Failed to validate rule.",
"rules.ruleSimulator.listPageTitle": "Simulateur",
"rules.ruleSyntax.if": "Si",
"rules.ruleSyntax.then": "alors",

13
backend/i18n/frontend_it.json

@ -336,6 +336,7 @@
"common.prevPage": "Previous Page",
"common.product": "Squidex Headless CMS",
"common.project": "Progetto",
"common.properties": "Properties",
"common.queryOperators.contains": "contiene",
"common.queryOperators.empty": "è vuoto",
"common.queryOperators.endsWith": "finisce con",
@ -714,6 +715,15 @@
"rules.conditions.commentKeyword": "Only for text keywords",
"rules.conditions.commentUser": "Specific users",
"rules.conditions.contentValue": "Content has value",
"rules.conditions.cronExpression": "Cron Expression",
"rules.conditions.cronExpressionEvery4Hours": "Every 4 hours",
"rules.conditions.cronExpressionEveryMonth": "First day every month, at 6 AM in the morning",
"rules.conditions.cronExpressionEveryMorning": "Every day at 6 AM in the morning",
"rules.conditions.cronExpressionHint": "A cron expression is a string consisting of six or seven subexpressions (fields) that describe individual details of the schedule. You cannot create expressions that trigger this flow more often than every 4 hours.",
"rules.conditions.cronExpressionsHint": "Typical cron expressions",
"rules.conditions.cronExpressionsTitle": "Cron Expressions",
"rules.conditions.cronTimezone": "Timezone",
"rules.conditions.cronTimezoneHint": "The timezone as IANA format to define how to calculate the next time the flow should be run. Use your local timezone if a job should run every morning before working hours.",
"rules.conditions.event": "Specific events",
"rules.conditions.images": "Images only",
"rules.conditions.largeAssets": "Large assets",
@ -761,6 +771,7 @@
"rules.ruleEvents.loadFailed": "Non è stato possibile caricare gli eventi. Per favore ricarica.",
"rules.ruleEvents.nextAttemptLabel": "Successivo",
"rules.ruleEvents.reloaded": "Eventi della regola ricaricati.",
"rules.ruleEvents.validationFailed": "Failed to validate rule.",
"rules.ruleSimulator.listPageTitle": "Simulator",
"rules.ruleSyntax.if": "Se",
"rules.ruleSyntax.then": "Allora",
@ -783,7 +794,7 @@
"rules.simulation.errorTooOld": "STOP: Event is too old.",
"rules.simulation.errorWrongEvent": "STOP: Event does not match to the trigger.",
"rules.simulation.errorWrongEventForTrigger": "STOP: Event does not match to the trigger.",
"rules.simulation.eventConditionEvaluated": "Enriched event is evaluated, whether it matchs to the conditions and javascript expressions in the trigger.",
"rules.simulation.eventConditionEvaluated": "Enriched is tested against the conditions and javascript expressions in the trigger.",
"rules.simulation.eventEnriched": "Event is enriched with additional data",
"rules.simulation.eventQueried": "Event is queried from the database",
"rules.simulation.eventTriggerChecked": "Event is tested to see if it matchs to the trigger and the basic conditions.",

11
backend/i18n/frontend_nl.json

@ -336,6 +336,7 @@
"common.prevPage": "Previous Page",
"common.product": "Squidex Headless CMS",
"common.project": "Project",
"common.properties": "Properties",
"common.queryOperators.contains": "bevat",
"common.queryOperators.empty": "is leeg",
"common.queryOperators.endsWith": "eindigt op",
@ -714,6 +715,15 @@
"rules.conditions.commentKeyword": "Only for text keywords",
"rules.conditions.commentUser": "Specific users",
"rules.conditions.contentValue": "Content has value",
"rules.conditions.cronExpression": "Cron Expression",
"rules.conditions.cronExpressionEvery4Hours": "Every 4 hours",
"rules.conditions.cronExpressionEveryMonth": "First day every month, at 6 AM in the morning",
"rules.conditions.cronExpressionEveryMorning": "Every day at 6 AM in the morning",
"rules.conditions.cronExpressionHint": "A cron expression is a string consisting of six or seven subexpressions (fields) that describe individual details of the schedule. You cannot create expressions that trigger this flow more often than every 4 hours.",
"rules.conditions.cronExpressionsHint": "Typical cron expressions",
"rules.conditions.cronExpressionsTitle": "Cron Expressions",
"rules.conditions.cronTimezone": "Timezone",
"rules.conditions.cronTimezoneHint": "The timezone as IANA format to define how to calculate the next time the flow should be run. Use your local timezone if a job should run every morning before working hours.",
"rules.conditions.event": "Specific events",
"rules.conditions.images": "Images only",
"rules.conditions.largeAssets": "Large assets",
@ -761,6 +771,7 @@
"rules.ruleEvents.loadFailed": "Kan evenementen niet laden. Laad opnieuw.",
"rules.ruleEvents.nextAttemptLabel": "Volgende",
"rules.ruleEvents.reloaded": "RuleEvents herladen.",
"rules.ruleEvents.validationFailed": "Failed to validate rule.",
"rules.ruleSimulator.listPageTitle": "Simulator",
"rules.ruleSyntax.if": "If",
"rules.ruleSyntax.then": "then",

11
backend/i18n/frontend_pt.json

@ -336,6 +336,7 @@
"common.prevPage": "Previous Page",
"common.product": "CMS Headless Squidex",
"common.project": "Projeto",
"common.properties": "Properties",
"common.queryOperators.contains": "contém",
"common.queryOperators.empty": "está vazio",
"common.queryOperators.endsWith": "termina com",
@ -714,6 +715,15 @@
"rules.conditions.commentKeyword": "Apenas para palavras-chave de texto",
"rules.conditions.commentUser": "Utilizadores específicos",
"rules.conditions.contentValue": "O conteúdo tem valor",
"rules.conditions.cronExpression": "Cron Expression",
"rules.conditions.cronExpressionEvery4Hours": "Every 4 hours",
"rules.conditions.cronExpressionEveryMonth": "First day every month, at 6 AM in the morning",
"rules.conditions.cronExpressionEveryMorning": "Every day at 6 AM in the morning",
"rules.conditions.cronExpressionHint": "A cron expression is a string consisting of six or seven subexpressions (fields) that describe individual details of the schedule. You cannot create expressions that trigger this flow more often than every 4 hours.",
"rules.conditions.cronExpressionsHint": "Typical cron expressions",
"rules.conditions.cronExpressionsTitle": "Cron Expressions",
"rules.conditions.cronTimezone": "Timezone",
"rules.conditions.cronTimezoneHint": "The timezone as IANA format to define how to calculate the next time the flow should be run. Use your local timezone if a job should run every morning before working hours.",
"rules.conditions.event": "Eventos específicos",
"rules.conditions.images": "Apenas imagens",
"rules.conditions.largeAssets": "Grandes ativos",
@ -761,6 +771,7 @@
"rules.ruleEvents.loadFailed": "Falhou em carregar eventos. Por favor, recarregue.",
"rules.ruleEvents.nextAttemptLabel": "A seguir",
"rules.ruleEvents.reloaded": "RegrasEventos recarregados.",
"rules.ruleEvents.validationFailed": "Failed to validate rule.",
"rules.ruleSimulator.listPageTitle": "Simulador",
"rules.ruleSyntax.if": "If",
"rules.ruleSyntax.then": "then",

13
backend/i18n/frontend_zh.json

@ -336,6 +336,7 @@
"common.prevPage": "Previous Page",
"common.product": "Squidex Headless CMS",
"common.project": "项目",
"common.properties": "Properties",
"common.queryOperators.contains": "包含",
"common.queryOperators.empty": "为空",
"common.queryOperators.endsWith": "以",
@ -714,6 +715,15 @@
"rules.conditions.commentKeyword": "Only for text keywords",
"rules.conditions.commentUser": "Specific users",
"rules.conditions.contentValue": "Content has value",
"rules.conditions.cronExpression": "Cron Expression",
"rules.conditions.cronExpressionEvery4Hours": "Every 4 hours",
"rules.conditions.cronExpressionEveryMonth": "First day every month, at 6 AM in the morning",
"rules.conditions.cronExpressionEveryMorning": "Every day at 6 AM in the morning",
"rules.conditions.cronExpressionHint": "A cron expression is a string consisting of six or seven subexpressions (fields) that describe individual details of the schedule. You cannot create expressions that trigger this flow more often than every 4 hours.",
"rules.conditions.cronExpressionsHint": "Typical cron expressions",
"rules.conditions.cronExpressionsTitle": "Cron Expressions",
"rules.conditions.cronTimezone": "Timezone",
"rules.conditions.cronTimezoneHint": "The timezone as IANA format to define how to calculate the next time the flow should be run. Use your local timezone if a job should run every morning before working hours.",
"rules.conditions.event": "Specific events",
"rules.conditions.images": "Images only",
"rules.conditions.largeAssets": "Large assets",
@ -761,6 +771,7 @@
"rules.ruleEvents.loadFailed": "加载事件失败。请重新加载。",
"rules.ruleEvents.nextAttemptLabel": "下一个",
"rules.ruleEvents.reloaded": "RuleEvents 重新加载。",
"rules.ruleEvents.validationFailed": "Failed to validate rule.",
"rules.ruleSimulator.listPageTitle": "模拟器",
"rules.ruleSyntax.if": "如果",
"rules.ruleSyntax.then": "那么",
@ -783,7 +794,7 @@
"rules.simulation.errorTooOld": "STOP: Event is too old.",
"rules.simulation.errorWrongEvent": "STOP: Event does not match to the trigger.",
"rules.simulation.errorWrongEventForTrigger": "STOP: Event does not match to the trigger.",
"rules.simulation.eventConditionEvaluated": "Enriched event is evaluated, whether it matchs to the conditions and javascript expressions in the trigger.",
"rules.simulation.eventConditionEvaluated": "Enriched is tested against the conditions and javascript expressions in the trigger.",
"rules.simulation.eventEnriched": "Event is enriched with additional data",
"rules.simulation.eventQueried": "Event is queried from the database",
"rules.simulation.eventTriggerChecked": "Event is tested to see if it matchs to the trigger and the basic conditions.",

4
backend/i18n/source/backend_en.json

@ -280,10 +280,13 @@
"login.test.headline": "Successful Test",
"login.test.text": "Login Test was successful. You can save the settings now. It is recommended to make a final test with the actual login flow.",
"login.test.title": "Login Test",
"rules.validation.invalidCronExpression": "Cron Expression is invalid.",
"rules.validation.invalidCronTimezone": "Timezone is not a valid IANA identifier.",
"rules.validation.invalidNextStepId": "Invalid next step ID",
"rules.validation.invalidStepId": "Invalid step ID",
"rules.validation.noStartStep": "Flow has no start step",
"rules.validation.noSteps": "Flow has no step",
"rules.validation.schemaNotFound": "Schema {id} does not exist.",
"schemas.dateTimeCalculatedDefaultAndDefaultError": "Calculated default value and default value cannot be used together.",
"schemas.duplicateFieldName": "Field '{field}' has been added twice.",
"schemas.fieldCannotBeUIField": "Field cannot be an UI field.",
@ -293,7 +296,6 @@
"schemas.fieldsNotCovered": "Field ids do not cover all fields.",
"schemas.nameAlreadyExists": "A schema with the same name already exists.",
"schemas.noPermission": "You do not have permission for this schema.",
"schemas.notFoundId": "Schema {id} does not exist.",
"schemas.number.inlineEditorError": "Inline editing is not allowed for Radio editor.",
"schemas.onlyArraysHaveNested": "Only array fields can have nested fields.",
"schemas.onylArraysInRoot": "Nested field cannot be array fields.",

2
backend/i18n/source/backend_fr.json

@ -261,6 +261,7 @@
"history.teams.planReset": "plan réinitialisé.",
"history.teams.updated": "paramètres généraux mis à jour et nom renommé en {[Name]}.",
"login.githubPrivateEmail": "Votre adresse e-mail est définie sur privé dans Github. Veuillez le définir sur public pour utiliser la connexion Github.",
"rules.validation.schemaNotFound": "Le schéma {id} n'existe pas.",
"schemas.dateTimeCalculatedDefaultAndDefaultError": "La valeur par défaut calculée et la valeur par défaut ne peuvent pas être utilisées ensemble.",
"schemas.duplicateFieldName": "Le champ '{field}' a été ajouté deux fois.",
"schemas.fieldCannotBeUIField": "Le champ ne peut pas être un champ d'interface utilisateur.",
@ -270,7 +271,6 @@
"schemas.fieldsNotCovered": "Les ID de champ ne couvrent pas tous les champs.",
"schemas.nameAlreadyExists": "Un schéma portant le même nom existe déjà.",
"schemas.noPermission": "Vous n'avez pas l'autorisation pour ce schéma.",
"schemas.notFoundId": "Le schéma {id} n'existe pas.",
"schemas.number.inlineEditorError": "L'édition en ligne n'est pas autorisée pour l'éditeur radio.",
"schemas.onlyArraysHaveNested": "Seuls les champs de tableau peuvent avoir des champs imbriqués.",
"schemas.onylArraysInRoot": "Les champs imbriqués ne peuvent pas être des champs de tableau.",

2
backend/i18n/source/backend_it.json

@ -231,6 +231,7 @@
"history.schemas.updated": "ha aggiornato lo schema {[Name]}.",
"history.statusChanged": "ha cambiato lo stato del contenuto {[Schema]} in {[Status]}.",
"login.githubPrivateEmail": "Il tuo indirizzo email è impostato su privato in Github. Impostalo come pubblico per poter utilizzare il login Github.",
"rules.validation.schemaNotFound": "Lo schema {id} non esiste.",
"schemas.dateTimeCalculatedDefaultAndDefaultError": "Il valore predefinito calcolato e il valore predefinito non possono essere utilizzati insieme.",
"schemas.duplicateFieldName": "Il campo '{field}' è stato aggiunto due volte.",
"schemas.fieldCannotBeUIField": "Il campo non può essere un campo UI.",
@ -240,7 +241,6 @@
"schemas.fieldsNotCovered": "Non tutti i campi hanno degli ID associati.",
"schemas.nameAlreadyExists": "Esiste già uno schema con lo stesso nome.",
"schemas.noPermission": "Non hai i permessi per questo schema.",
"schemas.notFoundId": "Lo schema {id} non esiste.",
"schemas.number.inlineEditorError": "Non è consentita per l'editor di tipo radio la modifica in linea.",
"schemas.onlyArraysHaveNested": "Solo i campi di tipo array possono avere campi annidati.",
"schemas.onylArraysInRoot": "Non è possibile annidare un campo di tipo array.",

2
backend/i18n/source/backend_nl.json

@ -242,6 +242,7 @@
"history.schemas.updated": "bijgewerkt schema {[Name]}.",
"history.statusChanged": "veranderde status van {[Schema]} inhoud in {[Status]}.",
"login.githubPrivateEmail": "Jouw e-mailadres is ingesteld op privé in Github. Stel het in op openbaar om Github-login te gebruiken.",
"rules.validation.schemaNotFound": "Schema {id} bestaat niet.",
"schemas.dateTimeCalculatedDefaultAndDefaultError": "Berekende standaardwaarde en standaardwaarde kunnen niet samen worden gebruikt.",
"schemas.duplicateFieldName": "Veld '{field}' is twee keer toegevoegd.",
"schemas.fieldCannotBeUIField": "Veld mag geen UI-veld zijn.",
@ -251,7 +252,6 @@
"schemas.fieldsNotCovered": "Veld-id's dekken niet alle velden.",
"schemas.nameAlreadyExists": "Er bestaat al een schema met dezelfde naam.",
"schemas.noPermission": "Je hebt geen toestemming voor dit schema.",
"schemas.notFoundId": "Schema {id} bestaat niet.",
"schemas.number.inlineEditorError": "Inline bewerken is niet toegestaan ​​voor Radio-editor.",
"schemas.onlyArraysHaveNested": "Alleen matrixvelden kunnen geneste velden bevatten.",
"schemas.onylArraysInRoot": "Genest veld mag geen matrixvelden zijn.",

2
backend/i18n/source/backend_pt.json

@ -260,6 +260,7 @@
"history.teams.planReset": "plano redifinido.",
"history.teams.updated": "actualizadas configurações gerais e renomeado para {[Name]}.",
"login.githubPrivateEmail": "O seu Email é privado no Github. Altere para publico no Github e tente novamente.",
"rules.validation.schemaNotFound": "Esquema {id} não existe.",
"schemas.dateTimeCalculatedDefaultAndDefaultError": "Valor por defeito calculado e valor por defeito não podem ser usado em conjunto.",
"schemas.duplicateFieldName": "Campo '{field}' foi adicionado em duplicado.",
"schemas.fieldCannotBeUIField": "Campo não pode ser um campo UI.",
@ -269,7 +270,6 @@
"schemas.fieldsNotCovered": "Ids do campo não cobrem todos os campos.",
"schemas.nameAlreadyExists": "Um esquema com o mesmo nome já existe.",
"schemas.noPermission": "Não tem permissões para este esquema.",
"schemas.notFoundId": "Esquema {id} não existe.",
"schemas.number.inlineEditorError": "Não é permitido alteração em linha no Radio editor.",
"schemas.onlyArraysHaveNested": "So campos lista podem ter campos aninhados.",
"schemas.onylArraysInRoot": "Campo aninhado não pode ser lista de campos.",

13
backend/i18n/source/frontend_en.json

@ -336,6 +336,7 @@
"common.prevPage": "Previous Page",
"common.product": "Squidex Headless CMS",
"common.project": "Project",
"common.properties": "Properties",
"common.queryOperators.contains": "contains",
"common.queryOperators.empty": "is empty",
"common.queryOperators.endsWith": "ends with",
@ -714,6 +715,15 @@
"rules.conditions.commentKeyword": "Only for text keywords",
"rules.conditions.commentUser": "Specific users",
"rules.conditions.contentValue": "Content has value",
"rules.conditions.cronExpression": "Cron Expression",
"rules.conditions.cronExpressionEvery4Hours": "Every 4 hours",
"rules.conditions.cronExpressionEveryMonth": "First day every month, at 6 AM in the morning",
"rules.conditions.cronExpressionEveryMorning": "Every day at 6 AM in the morning",
"rules.conditions.cronExpressionHint": "A cron expression is a string consisting of six or seven subexpressions (fields) that describe individual details of the schedule. You cannot create expressions that trigger this flow more often than every 4 hours.",
"rules.conditions.cronExpressionsHint": "Typical cron expressions",
"rules.conditions.cronExpressionsTitle": "Cron Expressions",
"rules.conditions.cronTimezone": "Timezone",
"rules.conditions.cronTimezoneHint": "The timezone as IANA format to define how to calculate the next time the flow should be run. Use your local timezone if a job should run every morning before working hours.",
"rules.conditions.event": "Specific events",
"rules.conditions.images": "Images only",
"rules.conditions.largeAssets": "Large assets",
@ -761,6 +771,7 @@
"rules.ruleEvents.loadFailed": "Failed to load events. Please reload.",
"rules.ruleEvents.nextAttemptLabel": "Next",
"rules.ruleEvents.reloaded": "RuleEvents reloaded.",
"rules.ruleEvents.validationFailed": "Failed to validate rule.",
"rules.ruleSimulator.listPageTitle": "Simulator",
"rules.ruleSyntax.if": "when",
"rules.ruleSyntax.then": "then",
@ -783,7 +794,7 @@
"rules.simulation.errorTooOld": "STOP: Event is too old.",
"rules.simulation.errorWrongEvent": "STOP: Event does not match to the trigger.",
"rules.simulation.errorWrongEventForTrigger": "STOP: Event does not match to the trigger.",
"rules.simulation.eventConditionEvaluated": "Enriched event is evaluated, whether it matchs to the conditions and javascript expressions in the trigger.",
"rules.simulation.eventConditionEvaluated": "Enriched is tested against the conditions and javascript expressions in the trigger.",
"rules.simulation.eventEnriched": "Event is enriched with additional data",
"rules.simulation.eventQueried": "Event is queried from the database",
"rules.simulation.eventTriggerChecked": "Event is tested to see if it matchs to the trigger and the basic conditions.",

3
backend/src/Squidex.Data.EntityFramework/AppDbContext.cs

@ -37,11 +37,12 @@ public abstract class AppDbContext(DbContextOptions options, IJsonSerializer jso
builder.UseAssetKeyValueStore<TusMetadata>();
builder.UseAssets(jsonSerializer, jsonColumnType);
builder.UseCache();
builder.UseCounters(jsonSerializer, jsonColumnType);
builder.UseChatStore();
builder.UseContent(jsonSerializer, jsonColumnType, string.Empty);
builder.UseContentReferences(string.Empty);
builder.UseContentTables();
builder.UseCounters(jsonSerializer, jsonColumnType);
builder.UseCronJobs();
builder.UseEvents(jsonSerializer, jsonColumnType);
builder.UseEventStore();
builder.UseFlows();

3
backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20250504173108_AddFlows.cs

@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable

1586
backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20250513192106_AddCronJobs.Designer.cs

File diff suppressed because it is too large

43
backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/20250513192106_AddCronJobs.cs

@ -0,0 +1,43 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Squidex.Providers.MySql.App.Migrations
{
/// <inheritdoc />
public partial class AddCronJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CronJobs",
columns: table => new
{
Id = table.Column<string>(type: "varchar(255)", maxLength: 255, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DueTime = table.Column<DateTimeOffset>(type: "datetime(6)", nullable: false),
Data = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_CronJobs", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_CronJobs_DueTime",
table: "CronJobs",
column: "DueTime");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CronJobs");
}
}
}

20
backend/src/Squidex.Data.EntityFramework/Providers/MySql/App/Migrations/MySqlDbContextModelSnapshot.cs

@ -1097,6 +1097,26 @@ namespace Squidex.Providers.MySql.Migrations
b.ToTable("EventPosition");
});
modelBuilder.Entity("Squidex.Flows.EntityFramework.EFCronJobEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<string>("Data")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTimeOffset>("DueTime")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("DueTime");
b.ToTable("CronJobs", (string)null);
});
modelBuilder.Entity("Squidex.Flows.EntityFramework.EFFlowStateEntity", b =>
{
b.Property<Guid>("Id")

3
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20250504173116_AddFlows.cs

@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable

1587
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20250513192113_AddCronJobs.Designer.cs

File diff suppressed because it is too large

40
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/20250513192113_AddCronJobs.cs

@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Squidex.Providers.Postgres.App.Migrations
{
/// <inheritdoc />
public partial class AddCronJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CronJobs",
columns: table => new
{
Id = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
DueTime = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Data = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CronJobs", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_CronJobs_DueTime",
table: "CronJobs",
column: "DueTime");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CronJobs");
}
}
}

20
backend/src/Squidex.Data.EntityFramework/Providers/Postgres/App/Migrations/PostgresDbContextModelSnapshot.cs

@ -1098,6 +1098,26 @@ namespace Squidex.Providers.Postgres.Migrations
b.ToTable("EventPosition");
});
modelBuilder.Entity("Squidex.Flows.EntityFramework.EFCronJobEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Data")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("DueTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("DueTime");
b.ToTable("CronJobs", (string)null);
});
modelBuilder.Entity("Squidex.Flows.EntityFramework.EFFlowStateEntity", b =>
{
b.Property<Guid>("Id")

3
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20250504173123_AddFlows.cs

@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable

1589
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20250513192120_AddCronJobs.Designer.cs

File diff suppressed because it is too large

40
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/20250513192120_AddCronJobs.cs

@ -0,0 +1,40 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Squidex.Providers.SqlServer.App.Migrations
{
/// <inheritdoc />
public partial class AddCronJobs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CronJobs",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(255)", maxLength: 255, nullable: false),
DueTime = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
Data = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CronJobs", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_CronJobs_DueTime",
table: "CronJobs",
column: "DueTime");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CronJobs");
}
}
}

20
backend/src/Squidex.Data.EntityFramework/Providers/SqlServer/App/Migrations/SqlServerDbContextModelSnapshot.cs

@ -1100,6 +1100,26 @@ namespace Squidex.Providers.SqlServer.Migrations
b.ToTable("EventPosition");
});
modelBuilder.Entity("Squidex.Flows.EntityFramework.EFCronJobEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<string>("Data")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset>("DueTime")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("DueTime");
b.ToTable("CronJobs", (string)null);
});
modelBuilder.Entity("Squidex.Flows.EntityFramework.EFFlowStateEntity", b =>
{
b.Property<Guid>("Id")

4
backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs

@ -10,7 +10,6 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Squidex.AI;
using Squidex.Assets.TusAdapter;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Assets;
@ -209,6 +208,9 @@ public static class ServiceExtensions
services.AddFlowsCore()
.AddEntityFrameworkStore<TContext, FlowEventContext>();
services.AddCronJobsCore()
.AddEntityFrameworkStore<TContext, CronJobContext>();
services.AddEntityFrameworkAssetKeyValueStore<TContext, TusMetadata>();
}

14
backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj

@ -36,17 +36,17 @@
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql.Json.Microsoft" Version="8.0.2" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql.NetTopologySuite" Version="8.0.2" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.EntityFramework" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.EntityFramework" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.3.0" />
<PackageReference Include="Squidex.AI.EntityFramework" Version="7.15.0" />
<PackageReference Include="Squidex.Assets.EntityFramework" Version="7.15.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.15.0" />
<PackageReference Include="Squidex.EFCore.BulkExtensions.Core" Version="8.1.6" />
<PackageReference Include="Squidex.EFCore.BulkExtensions.MySql" Version="8.1.6" />
<PackageReference Include="Squidex.EFCore.BulkExtensions.PostgreSQL" Version="8.1.6" />
<PackageReference Include="Squidex.EFCore.BulkExtensions.SqlServer" Version="8.1.6" />
<PackageReference Include="Squidex.Events.EntityFramework" Version="7.3.0" />
<PackageReference Include="Squidex.Flows.EntityFramework" Version="7.3.0" />
<PackageReference Include="Squidex.Hosting" Version="7.3.0" />
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="7.3.0" />
<PackageReference Include="Squidex.Events.EntityFramework" Version="7.15.0" />
<PackageReference Include="Squidex.Flows.EntityFramework" Version="7.15.0" />
<PackageReference Include="Squidex.Hosting" Version="7.15.0" />
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="7.15.0" />
<PackageReference Include="Squidex.OpenIdDict.EntityFramework" Version="5.8.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

6
backend/src/Squidex.Data.MongoDb/ServiceExtensions.cs

@ -16,7 +16,6 @@ using MongoDB.Bson;
using MongoDB.Driver;
using MongoDB.Driver.Core.Extensions.DiagnosticSources;
using MongoDB.Driver.GridFS;
using Squidex.AI;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.Contents;
@ -186,7 +185,10 @@ public static class ServiceExtensions
.As<IUserStore<IdentityUser>>().As<IUserFactory>();
services.AddFlowsCore()
.AddMongoFlowStore<FlowEventContext>();
.AddMongoStore<FlowEventContext>();
services.AddCronJobsCore()
.AddMongoStore<CronJobContext>();
services.AddSingletonAs(c =>
{

12
backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj

@ -25,12 +25,12 @@
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.30.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.Mongo" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="7.3.0" />
<PackageReference Include="Squidex.Events.Mongo" Version="7.3.0" />
<PackageReference Include="Squidex.Flows.Mongo" Version="7.3.0" />
<PackageReference Include="Squidex.Hosting" Version="7.3.0" />
<PackageReference Include="Squidex.Messaging.Mongo" Version="7.3.0" />
<PackageReference Include="Squidex.AI.Mongo" Version="7.15.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="7.15.0" />
<PackageReference Include="Squidex.Events.Mongo" Version="7.15.0" />
<PackageReference Include="Squidex.Flows.Mongo" Version="7.15.0" />
<PackageReference Include="Squidex.Hosting" Version="7.15.0" />
<PackageReference Include="Squidex.Messaging.Mongo" Version="7.15.0" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="5.8.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

20
backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedCronJobEvent.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
public sealed class EnrichedCronJobEvent : EnrichedUserEventBase
{
public JsonValue Value { get; set; }
public override long Partition
{
get => 0;
}
}

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

@ -17,6 +17,8 @@ public interface IRuleTriggerVisitor<out T, in TArgs>
T Visit(CommentTrigger trigger, TArgs args);
T Visit(CronJobTrigger trigger, TArgs args);
T Visit(ManualTrigger trigger, TArgs args);
T Visit(SchemaChangedTrigger trigger, TArgs args);

26
backend/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/CronJobTrigger.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Core.Rules.Triggers;
[TypeName(nameof(CronJobTrigger))]
public sealed record CronJobTrigger : RuleTrigger
{
public string CronExpression { get; init; }
public string? CronTimezone { get; init; }
public JsonValue Value { get; init; }
public override T Accept<T, TArgs>(IRuleTriggerVisitor<T, TArgs> visitor, TArgs args)
{
return visitor.Visit(this, args);
}
}

2
backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj

@ -20,7 +20,7 @@
<PackageReference Include="NetTopologySuite" Version="2.5.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Flows" Version="7.3.0" />
<PackageReference Include="Squidex.Flows" Version="7.15.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />

14
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/CronJobContext.cs

@ -0,0 +1,14 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Domain.Apps.Core.HandleRules;
public sealed record CronJobContext(NamedId<DomainId> AppId, DomainId RuleId);

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

@ -29,8 +29,8 @@
<PackageReference Include="NJsonSchema" Version="11.0.2" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI" Version="7.3.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.3.0" />
<PackageReference Include="Squidex.AI" Version="7.15.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.15.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />

44
backend/src/Squidex.Domain.Apps.Entities/Rules/CronJobTriggerHandler.cs

@ -0,0 +1,44 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Runtime.CompilerServices;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Rules;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Rules;
public sealed class CronJobTriggerHandler : IRuleTriggerHandler
{
public Type TriggerType => typeof(CronJobTrigger);
public bool Handles(AppEvent appEvent)
{
return appEvent is RuleCronJobTriggered;
}
public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RulesContext context,
[EnumeratorCancellation] CancellationToken ct)
{
var result = new EnrichedCronJobEvent();
// Use the concrete event to map properties that are not part of app event.
SimpleMapper.Map((RuleCronJobTriggered)@event.Payload, result);
await Task.Yield();
yield return result;
}
public string? GetName(AppEvent @event)
{
return "CronJob";
}
}

90
backend/src/Squidex.Domain.Apps.Entities/Rules/CronJobUpdater.cs

@ -0,0 +1,90 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Events.Rules;
using Squidex.Events;
using Squidex.Flows.CronJobs;
using Squidex.Hosting;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Rules;
public sealed class CronJobUpdater(
IAppProvider appProvider,
ICronJobManager<CronJobContext> cronJobs,
IRuleEnqueuer ruleEnqueuer)
: IEventConsumer, IInitializable
{
public StreamFilter EventsFilter => StreamFilter.Prefix("rule-");
public Task InitializeAsync(
CancellationToken ct)
{
cronJobs.Subscribe(HandleCronJobAsync);
return Task.CompletedTask;
}
public async Task HandleCronJobAsync(CronJob<CronJobContext> job,
CancellationToken ct)
{
var (appId, ruleId) = job.Context;
var rule = await appProvider.GetRuleAsync(appId.Id, ruleId, ct);
// The rule might have been updated or deleted in the meantime, but we are running asynchronously.
if (rule == null || rule.Trigger is not CronJobTrigger cronJob)
{
return;
}
// The rule enqueue needs an event.
var @event = new RuleCronJobTriggered { AppId = appId, RuleId = ruleId, Value = cronJob.Value };
await ruleEnqueuer.EnqueueAsync(rule, Envelope.Create(@event), ct);
}
public async Task On(Envelope<IEvent> @event)
{
if (@event.Payload is RuleCreated created)
{
if (created.Trigger is CronJobTrigger cronJob)
{
await AddCronJobAsync(created.AppId, created.RuleId, cronJob, default);
}
}
else if (@event.Payload is RuleUpdated updated && updated.Trigger != null)
{
if (updated.Trigger is CronJobTrigger cronJob)
{
await AddCronJobAsync(updated.AppId, updated.RuleId, cronJob, default);
}
else
{
await cronJobs.RemoveAsync(updated.RuleId.ToString());
}
}
else if (@event.Payload is RuleDeleted deleted)
{
await cronJobs.RemoveAsync(deleted.RuleId.ToString());
}
}
private async Task AddCronJobAsync(NamedId<DomainId> appId, DomainId id, CronJobTrigger trigger,
CancellationToken ct)
{
await cronJobs.AddAsync(new CronJob<CronJobContext>
{
Id = id.ToString(),
CronExpression = trigger.CronExpression,
CronTimezone = trigger.CronTimezone,
Context = new CronJobContext(appId, id),
}, ct);
}
}

3
backend/src/Squidex.Domain.Apps.Entities/Rules/DomainObject/Guards/GuardRule.cs

@ -43,8 +43,7 @@ public static class GuardRule
});
}
public static Task CanUpdate(UpdateRule command, Rule rule, IRuleValidator validator,
CancellationToken ct)
public static Task CanUpdate(UpdateRule command, IRuleValidator validator, CancellationToken ct)
{
Guard.NotNull(command);

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

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Org.BouncyCastle.Pkcs;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Rules;

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

@ -72,7 +72,7 @@ public partial class RuleDomainObject(
case UpdateRule updateRule:
return ApplyReturnAsync(updateRule, async (c, ct) =>
{
await GuardRule.CanUpdate(c, Snapshot, Validator(), ct);
await GuardRule.CanUpdate(c, Validator(), ct);
Update(c);
@ -122,7 +122,7 @@ public partial class RuleDomainObject(
SimpleMapper.Map(command, @event);
SimpleMapper.Map(Snapshot, @event);
await Enqueuer().EnqueueAsync(Snapshot.Id, Snapshot, Envelope.Create(@event));
await Enqueuer().EnqueueAsync(Snapshot, Envelope.Create(@event));
}
private void Create(CreateRule command)

3
backend/src/Squidex.Domain.Apps.Entities/Rules/IRuleEnqueuer.cs

@ -13,5 +13,6 @@ namespace Squidex.Domain.Apps.Entities.Rules;
public interface IRuleEnqueuer
{
Task EnqueueAsync(DomainId ruleId, Rule rule, Envelope<IEvent> @event);
Task EnqueueAsync(Rule rule, Envelope<IEvent> @event,
CancellationToken ct = default);
}

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

@ -43,10 +43,11 @@ public sealed class RuleEnqueuer(
get => GetType().Name;
}
public async Task EnqueueAsync(DomainId ruleId, Rule rule, Envelope<IEvent> @event)
public async Task EnqueueAsync(Rule rule, Envelope<IEvent> @event,
CancellationToken ct = default)
{
Guard.NotNull(rule);
Guard.NotNull(@event, nameof(@event));
Guard.NotNull(@event);
if (@event.Payload is not AppEvent appEvent)
{
@ -58,16 +59,14 @@ public sealed class RuleEnqueuer(
AppId = appEvent.AppId,
IncludeSkipped = false,
IncludeStale = false,
Rules = new Dictionary<DomainId, Rule>
Rules = rule != null ? new Dictionary<DomainId, Rule>
{
[ruleId] = rule,
}.ToReadonlyDictionary(),
[rule.Id] = rule,
}.ToReadonlyDictionary() : [],
};
// Write in batches of 100 items for better performance. Dispose completes the last write.
await using var batch = new RuleQueueWriter(flowManager, ruleUsageTracker, log);
await foreach (var result in ruleService.CreateJobsAsync(@event, context))
await foreach (var result in ruleService.CreateJobsAsync(@event, context, ct))
{
await batch.WriteAsync(appEvent.AppId.Id, result);
}

26
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleJobUpdate.cs

@ -1,26 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using NodaTime;
using Squidex.Domain.Apps.Core.HandleRules;
namespace Squidex.Domain.Apps.Entities.Rules;
public sealed class RuleJobUpdate
{
public string? ExecutionDump { get; set; }
public RuleResult ExecutionResult { get; set; }
public RuleJobResult JobResult { get; set; }
public TimeSpan Elapsed { get; set; }
public Instant Finished { get; set; }
public Instant? JobNext { get; set; }
}

27
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleValidator.cs

@ -9,6 +9,7 @@ using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Flows;
using Squidex.Flows.CronJobs;
using Squidex.Flows.Internal;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations;
@ -16,7 +17,11 @@ using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Rules;
public sealed class RuleValidator(IFlowManager<FlowEventContext> flowManager, IAppProvider appProvider) : IRuleValidator
public sealed class RuleValidator(
IFlowManager<FlowEventContext> flowManager,
ICronJobManager<CronJobContext> cronJobs,
IAppProvider appProvider)
: IRuleValidator
{
public async Task ValidateTriggerAsync(RuleTrigger trigger, DomainId appId, AddValidation addError,
CancellationToken ct = default)
@ -24,7 +29,7 @@ public sealed class RuleValidator(IFlowManager<FlowEventContext> flowManager, IA
Guard.NotNull(trigger);
Guard.NotNull(addError);
var context = new TriggerValidationContext(appId, addError, appProvider, ct);
var context = new TriggerValidationContext(appId, addError, appProvider, cronJobs, ct);
await trigger.Accept(RuleTriggerValidator.Instance, context);
}
@ -106,6 +111,21 @@ public sealed class RuleValidator(IFlowManager<FlowEventContext> flowManager, IA
return default;
}
public ValueTask<None> Visit(CronJobTrigger trigger, TriggerValidationContext args)
{
if (!args.CronJobs.IsValidCronExpression(trigger.CronExpression))
{
args.AddError(T.Get("rules.validation.invalidCronExpression"), nameof(trigger.CronExpression));
}
if (!args.CronJobs.IsValidTimezone(trigger.CronTimezone))
{
args.AddError(T.Get("rules.validation.invalidCronTimezone"), nameof(trigger.CronTimezone));
}
return default;
}
public async ValueTask<None> Visit(ContentChangedTriggerV2 trigger, TriggerValidationContext args)
{
if (trigger.Schemas == null)
@ -121,7 +141,7 @@ public sealed class RuleValidator(IFlowManager<FlowEventContext> flowManager, IA
}
else if (await args.AppProvider.GetSchemaAsync(args.AppId, schema.SchemaId, false, args.CancellationToken) == null)
{
args.AddError(T.Get("schemas.notFoundId", new { id = schema.SchemaId }), nameof(trigger.Schemas));
args.AddError(T.Get("rules.validation.schemaNotFound", new { id = schema.SchemaId }), nameof(trigger.Schemas));
}
}
@ -135,6 +155,7 @@ public sealed class RuleValidator(IFlowManager<FlowEventContext> flowManager, IA
DomainId AppId,
AddValidation AddError,
IAppProvider AppProvider,
ICronJobManager<CronJobContext> CronJobs,
CancellationToken CancellationToken);
#pragma warning restore RECS0082 // Parameter has the same name as a member and hides it
#pragma warning restore SA1313 // Parameter names should begin with lower-case letter

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Runtime.CompilerServices;
using NodaTime;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.HandleRules;
@ -12,8 +13,10 @@ using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Entities.Jobs;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Rules;
using Squidex.Events;
using Squidex.Flows;
using Squidex.Flows.Internal;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
using Squidex.Infrastructure.EventSourcing;
@ -29,60 +32,67 @@ public sealed class DefaultRuleRunnerService(
: IRuleRunnerService
{
private const int MaxSimulatedEvents = 100;
private static readonly RefToken SimulatorUser = RefToken.Client("Simulator");
public Task<List<SimulatedRuleEvent>> SimulateAsync(Rule rule,
public IClock Clock { get; set; } = SystemClock.Instance;
public Task<List<SimulatedRuleEvent>> SimulateAsync(NamedId<DomainId> appId, RuleTrigger trigger, FlowDefinition flow,
CancellationToken ct = default)
{
return SimulateAsync(rule.AppId, rule.Id, rule, ct);
var now = SystemClock.Instance.GetCurrentInstant();
var rule = new Rule
{
AppId = appId,
Created = now,
CreatedBy = SimulatorUser,
Flow = flow,
IsEnabled = true,
LastModified = now,
LastModifiedBy = SimulatorUser,
Trigger = trigger,
};
return SimulateAsync(rule, ct);
}
public async Task<List<SimulatedRuleEvent>> SimulateAsync(NamedId<DomainId> appId, DomainId ruleId, Rule rule,
public async Task<List<SimulatedRuleEvent>> SimulateAsync(Rule rule,
CancellationToken ct = default)
{
Guard.NotNull(rule);
var context = new RulesContext
{
AppId = appId,
AppId = rule.AppId,
IncludeSkipped = true,
IncludeStale = true,
Rules = new Dictionary<DomainId, Rule>
{
[ruleId] = rule,
[rule.Id] = rule,
}.ToReadonlyDictionary(),
};
var simulatedEvents = new List<SimulatedRuleEvent>(MaxSimulatedEvents);
var streamStart = SystemClock.Instance.GetCurrentInstant().Minus(Duration.FromDays(7)).ToDateTimeUtc();
var streamFilter = StreamFilter.Prefix($"%-{appId.Id}");
await foreach (var storedEvent in eventStore.QueryAllReverseAsync(streamFilter, streamStart, MaxSimulatedEvents, ct))
await foreach (var appEvent in QueryEventsAsync(rule, ct))
{
var @event = eventFormatter.ParseIfKnown(storedEvent);
if (@event?.Payload is not AppEvent appEvent)
{
continue;
}
// Also create jobs for rules with failing conditions because we want to show them in the table.
await foreach (var job in ruleService.CreateJobsAsync(@event, context, ct).Take(MaxSimulatedEvents).WithCancellation(ct))
await foreach (var job in ruleService.CreateJobsAsync(appEvent, context, ct).Take(MaxSimulatedEvents).WithCancellation(ct))
{
var state =
job.Job != null ?
await flowManager.SimulateAsync(job.Job.Value, ct) :
null;
var eventId = @event.Headers.EventId();
var eventId = appEvent.Headers.EventId();
simulatedEvents.Add(new SimulatedRuleEvent
{
EnrichedEvent = job.EnrichedEvent,
Error = job.EnrichmentError?.Message,
Event = @event.Payload,
Event = appEvent.Payload,
EventId = eventId,
EventName = ruleService.GetName(appEvent),
EventName = ruleService.GetName(appEvent.Payload),
State = state,
SkipReason = job.SkipReason,
UniqueId = $"{eventId}_{job.Offset}",
@ -93,6 +103,52 @@ public sealed class DefaultRuleRunnerService(
return simulatedEvents;
}
private async IAsyncEnumerable<Envelope<AppEvent>> QueryEventsAsync(Rule rule,
[EnumeratorCancellation] CancellationToken ct)
{
var appId = rule.AppId;
if (rule.Trigger is ManualTrigger)
{
yield return Envelope.Create<AppEvent>(
new RuleManuallyTriggered
{
Actor = SimulatorUser,
AppId = appId,
RuleId = rule.Id,
});
yield break;
}
if (rule.Trigger is CronJobTrigger cronJob)
{
yield return Envelope.Create<AppEvent>(
new RuleCronJobTriggered
{
Actor = SimulatorUser,
AppId = appId,
RuleId = rule.Id,
Value = cronJob.Value,
});
yield break;
}
var streamStart = SystemClock.Instance.GetCurrentInstant().Minus(Duration.FromDays(7)).ToDateTimeUtc();
var streamFilter = StreamFilter.Prefix($"%-{appId.Id}");
await foreach (var storedEvent in eventStore.QueryAllReverseAsync(streamFilter, streamStart, MaxSimulatedEvents, ct))
{
var @event = eventFormatter.ParseIfKnown(storedEvent);
if (@event?.Payload is AppEvent)
{
yield return @event.To<AppEvent>();
}
}
}
public bool CanRunRule(Rule rule)
{
return rule.Trigger is not ManualTrigger;

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

@ -7,13 +7,14 @@
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Flows.Internal;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Rules.Runner;
public interface IRuleRunnerService
{
Task<List<SimulatedRuleEvent>> SimulateAsync(NamedId<DomainId> appId, DomainId ruleId, Rule rule,
Task<List<SimulatedRuleEvent>> SimulateAsync(NamedId<DomainId> appId, RuleTrigger trigger, FlowDefinition flow,
CancellationToken ct = default);
Task<List<SimulatedRuleEvent>> SimulateAsync(Rule rule,

12
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleJobResult.cs → backend/src/Squidex.Domain.Apps.Events/Rules/RuleCronJobTriggered.cs

@ -5,13 +5,11 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Rules;
using Squidex.Infrastructure.Json.Objects;
public enum RuleJobResult
namespace Squidex.Domain.Apps.Events.Rules;
public sealed class RuleCronJobTriggered : RuleEvent
{
Pending,
Success,
Retry,
Failed,
Cancelled,
public JsonValue Value { get; set; }
}

2
backend/src/Squidex.Infrastructure/Commands/DomainObject.cs

@ -166,7 +166,7 @@ public abstract partial class DomainObject<T> : IAggregate where T : Entity, new
protected virtual void RaiseEvent(Envelope<IEvent> @event)
{
Guard.NotNull(@event, nameof(@event));
Guard.NotNull(@event);
@event.SetAggregateId(uniqueId);

105
backend/src/Squidex.Infrastructure/Http/DumpFormatter.cs

@ -1,105 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
namespace Squidex.Infrastructure.Http;
public static class DumpFormatter
{
public static string BuildDump(HttpRequestMessage request, HttpResponseMessage? response, string? responseBody)
{
return BuildDump(request, response, null, responseBody, TimeSpan.Zero);
}
public static string BuildDump(HttpRequestMessage request, HttpResponseMessage? response, string? requestBody, string? responseBody)
{
return BuildDump(request, response, requestBody, responseBody, TimeSpan.Zero);
}
public static string BuildDump(HttpRequestMessage request, HttpResponseMessage? response, string? requestBody, string? responseBody, TimeSpan elapsed, bool isTimeout = false)
{
var writer = new StringBuilder();
writer.AppendLine("Request:");
writer.AppendRequest(request, requestBody);
writer.AppendLine();
writer.AppendLine();
writer.AppendLine("Response:");
writer.AppendResponse(response, responseBody, elapsed, isTimeout);
return writer.ToString();
}
private static void AppendRequest(this StringBuilder writer, HttpRequestMessage request, string? requestBody)
{
var method = request.Method.ToString().ToUpperInvariant();
writer.AppendLine(CultureInfo.InvariantCulture, $"{method}: {request.RequestUri} HTTP/{request.Version}");
writer.AppendHeaders(request.Headers);
writer.AppendHeaders(request.Content?.Headers);
if (!string.IsNullOrWhiteSpace(requestBody))
{
writer.AppendLine();
writer.AppendLine(requestBody);
}
}
private static void AppendResponse(this StringBuilder writer, HttpResponseMessage? response, string? responseBody, TimeSpan elapsed, bool isTimeout)
{
if (response != null)
{
var responseCode = (int)response.StatusCode;
var responseText = Enum.GetName(typeof(HttpStatusCode), response.StatusCode);
writer.AppendLine(CultureInfo.InvariantCulture, $"HTTP/{response.Version} {responseCode} {responseText}");
writer.AppendHeaders(response.Headers);
writer.AppendHeaders(response.Content?.Headers);
}
if (!string.IsNullOrWhiteSpace(responseBody))
{
writer.AppendLine();
writer.AppendLine(responseBody);
}
if (response != null && elapsed != TimeSpan.Zero)
{
writer.AppendLine();
writer.AppendLine(CultureInfo.InvariantCulture, $"Elapsed: {elapsed}");
}
if (isTimeout)
{
writer.AppendLine(CultureInfo.InvariantCulture, $"Timeout after {elapsed}");
}
}
private static void AppendHeaders(this StringBuilder writer, HttpHeaders? headers)
{
if (headers == null)
{
return;
}
foreach (var (key, value) in headers)
{
writer.Append(key);
writer.Append(": ");
writer.Append(string.Join("; ", value));
writer.AppendLine();
}
}
}

1
backend/src/Squidex.Infrastructure/Queries/OData/FilterVisitor.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System.Text.RegularExpressions;
using Google.Protobuf;
using Microsoft.OData.UriParser;
using Microsoft.Spatial;

14
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -24,13 +24,13 @@
<PackageReference Include="NodaTime" Version="3.2.0" />
<PackageReference Include="OpenTelemetry.Api" Version="1.9.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="7.3.0" />
<PackageReference Include="Squidex.Caching" Version="7.3.0" />
<PackageReference Include="Squidex.Events" Version="7.3.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="7.3.0" />
<PackageReference Include="Squidex.Log" Version="7.3.0" />
<PackageReference Include="Squidex.Messaging" Version="7.3.0" />
<PackageReference Include="Squidex.Text" Version="7.3.0" />
<PackageReference Include="Squidex.Assets" Version="7.15.0" />
<PackageReference Include="Squidex.Caching" Version="7.15.0" />
<PackageReference Include="Squidex.Events" Version="7.15.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="7.15.0" />
<PackageReference Include="Squidex.Log" Version="7.15.0" />
<PackageReference Include="Squidex.Messaging" Version="7.15.0" />
<PackageReference Include="Squidex.Text" Version="7.15.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />

12
backend/src/Squidex.Shared/Texts.fr.resx

@ -925,6 +925,12 @@
<data name="login.test.title" xml:space="preserve">
<value>Login Test</value>
</data>
<data name="rules.validation.invalidCronExpression" xml:space="preserve">
<value>Cron Expression is invalid.</value>
</data>
<data name="rules.validation.invalidCronTimezone" xml:space="preserve">
<value>Timezone is not a valid IANA identifier.</value>
</data>
<data name="rules.validation.invalidNextStepId" xml:space="preserve">
<value>Invalid next step ID</value>
</data>
@ -937,6 +943,9 @@
<data name="rules.validation.noSteps" xml:space="preserve">
<value>Flow has no step</value>
</data>
<data name="rules.validation.schemaNotFound" xml:space="preserve">
<value>Le schéma {id} n'existe pas.</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>La valeur par défaut calculée et la valeur par défaut ne peuvent pas être utilisées ensemble.</value>
</data>
@ -964,9 +973,6 @@
<data name="schemas.noPermission" xml:space="preserve">
<value>Vous n'avez pas l'autorisation pour ce schéma.</value>
</data>
<data name="schemas.notFoundId" xml:space="preserve">
<value>Le schéma {id} n'existe pas.</value>
</data>
<data name="schemas.number.inlineEditorError" xml:space="preserve">
<value>L'édition en ligne n'est pas autorisée pour l'éditeur radio.</value>
</data>

12
backend/src/Squidex.Shared/Texts.it.resx

@ -925,6 +925,12 @@
<data name="login.test.title" xml:space="preserve">
<value>Login Test</value>
</data>
<data name="rules.validation.invalidCronExpression" xml:space="preserve">
<value>Cron Expression is invalid.</value>
</data>
<data name="rules.validation.invalidCronTimezone" xml:space="preserve">
<value>Timezone is not a valid IANA identifier.</value>
</data>
<data name="rules.validation.invalidNextStepId" xml:space="preserve">
<value>Invalid next step ID</value>
</data>
@ -937,6 +943,9 @@
<data name="rules.validation.noSteps" xml:space="preserve">
<value>Flow has no step</value>
</data>
<data name="rules.validation.schemaNotFound" xml:space="preserve">
<value>Lo schema {id} non esiste.</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>Il valore predefinito calcolato e il valore predefinito non possono essere utilizzati insieme.</value>
</data>
@ -964,9 +973,6 @@
<data name="schemas.noPermission" xml:space="preserve">
<value>Non hai i permessi per questo schema.</value>
</data>
<data name="schemas.notFoundId" xml:space="preserve">
<value>Lo schema {id} non esiste.</value>
</data>
<data name="schemas.number.inlineEditorError" xml:space="preserve">
<value>Non è consentita per l'editor di tipo radio la modifica in linea.</value>
</data>

12
backend/src/Squidex.Shared/Texts.nl.resx

@ -925,6 +925,12 @@
<data name="login.test.title" xml:space="preserve">
<value>Login Test</value>
</data>
<data name="rules.validation.invalidCronExpression" xml:space="preserve">
<value>Cron Expression is invalid.</value>
</data>
<data name="rules.validation.invalidCronTimezone" xml:space="preserve">
<value>Timezone is not a valid IANA identifier.</value>
</data>
<data name="rules.validation.invalidNextStepId" xml:space="preserve">
<value>Invalid next step ID</value>
</data>
@ -937,6 +943,9 @@
<data name="rules.validation.noSteps" xml:space="preserve">
<value>Flow has no step</value>
</data>
<data name="rules.validation.schemaNotFound" xml:space="preserve">
<value>Schema {id} bestaat niet.</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>Berekende standaardwaarde en standaardwaarde kunnen niet samen worden gebruikt.</value>
</data>
@ -964,9 +973,6 @@
<data name="schemas.noPermission" xml:space="preserve">
<value>Je hebt geen toestemming voor dit schema.</value>
</data>
<data name="schemas.notFoundId" xml:space="preserve">
<value>Schema {id} bestaat niet.</value>
</data>
<data name="schemas.number.inlineEditorError" xml:space="preserve">
<value>Inline bewerken is niet toegestaan ​​voor Radio-editor.</value>
</data>

12
backend/src/Squidex.Shared/Texts.pt.resx

@ -925,6 +925,12 @@
<data name="login.test.title" xml:space="preserve">
<value>Login Test</value>
</data>
<data name="rules.validation.invalidCronExpression" xml:space="preserve">
<value>Cron Expression is invalid.</value>
</data>
<data name="rules.validation.invalidCronTimezone" xml:space="preserve">
<value>Timezone is not a valid IANA identifier.</value>
</data>
<data name="rules.validation.invalidNextStepId" xml:space="preserve">
<value>Invalid next step ID</value>
</data>
@ -937,6 +943,9 @@
<data name="rules.validation.noSteps" xml:space="preserve">
<value>Flow has no step</value>
</data>
<data name="rules.validation.schemaNotFound" xml:space="preserve">
<value>Esquema {id} não existe.</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>Valor por defeito calculado e valor por defeito não podem ser usado em conjunto.</value>
</data>
@ -964,9 +973,6 @@
<data name="schemas.noPermission" xml:space="preserve">
<value>Não tem permissões para este esquema.</value>
</data>
<data name="schemas.notFoundId" xml:space="preserve">
<value>Esquema {id} não existe.</value>
</data>
<data name="schemas.number.inlineEditorError" xml:space="preserve">
<value>Não é permitido alteração em linha no Radio editor.</value>
</data>

12
backend/src/Squidex.Shared/Texts.resx

@ -925,6 +925,12 @@
<data name="login.test.title" xml:space="preserve">
<value>Login Test</value>
</data>
<data name="rules.validation.invalidCronExpression" xml:space="preserve">
<value>Cron Expression is invalid.</value>
</data>
<data name="rules.validation.invalidCronTimezone" xml:space="preserve">
<value>Timezone is not a valid IANA identifier.</value>
</data>
<data name="rules.validation.invalidNextStepId" xml:space="preserve">
<value>Invalid next step ID</value>
</data>
@ -937,6 +943,9 @@
<data name="rules.validation.noSteps" xml:space="preserve">
<value>Flow has no step</value>
</data>
<data name="rules.validation.schemaNotFound" xml:space="preserve">
<value>Schema {id} does not exist.</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>Calculated default value and default value cannot be used together.</value>
</data>
@ -964,9 +973,6 @@
<data name="schemas.noPermission" xml:space="preserve">
<value>You do not have permission for this schema.</value>
</data>
<data name="schemas.notFoundId" xml:space="preserve">
<value>Schema {id} does not exist.</value>
</data>
<data name="schemas.number.inlineEditorError" xml:space="preserve">
<value>Inline editing is not allowed for Radio editor.</value>
</data>

12
backend/src/Squidex.Shared/Texts.zh.resx

@ -925,6 +925,12 @@
<data name="login.test.title" xml:space="preserve">
<value>Login Test</value>
</data>
<data name="rules.validation.invalidCronExpression" xml:space="preserve">
<value>Cron Expression is invalid.</value>
</data>
<data name="rules.validation.invalidCronTimezone" xml:space="preserve">
<value>Timezone is not a valid IANA identifier.</value>
</data>
<data name="rules.validation.invalidNextStepId" xml:space="preserve">
<value>Invalid next step ID</value>
</data>
@ -937,6 +943,9 @@
<data name="rules.validation.noSteps" xml:space="preserve">
<value>Flow has no step</value>
</data>
<data name="rules.validation.schemaNotFound" xml:space="preserve">
<value>Schema {id} does not exist.</value>
</data>
<data name="schemas.dateTimeCalculatedDefaultAndDefaultError" xml:space="preserve">
<value>计算出的默认值和默认值不能一起使用。</value>
</data>
@ -964,9 +973,6 @@
<data name="schemas.noPermission" xml:space="preserve">
<value>You do not have permission for this schema.</value>
</data>
<data name="schemas.notFoundId" xml:space="preserve">
<value>Schema {id} does not exist.</value>
</data>
<data name="schemas.number.inlineEditorError" xml:space="preserve">
<value>无线电编辑器不允许内联编辑。</value>
</data>

1
backend/src/Squidex/Areas/Api/Config/OpenApi/DiscriminatorProcessor.cs

@ -10,7 +10,6 @@ using NJsonSchema;
using NJsonSchema.Generation;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Infrastructure.Reflection;
using System.Diagnostics;
namespace Squidex.Areas.Api.Config.OpenApi;

11
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -30,6 +30,7 @@ public sealed class AssetsController(
ICommandBus commandBus,
IAssetQueryService assetQuery,
ITagService tagService,
ScriptingCompleter scriptingCompleter,
AssetTusRunner assetTusRunner)
: ApiController(commandBus)
{
@ -384,10 +385,9 @@ public sealed class AssetsController(
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
[ApiExplorerSettings(IgnoreApi = true)]
public IActionResult GetScriptCompletion(string app, string schema,
[FromServices] ScriptingCompleter completer)
public IActionResult GetScriptCompletion(string app, string schema)
{
var completion = completer.AssetScript();
var completion = scriptingCompleter.AssetScript();
return Ok(completion);
}
@ -397,10 +397,9 @@ public sealed class AssetsController(
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
[ApiExplorerSettings(IgnoreApi = true)]
public IActionResult GetScriptTriggerCompletion(string app, string schema,
[FromServices] ScriptingCompleter completer)
public IActionResult GetScriptTriggerCompletion(string app, string schema)
{
var completion = completer.AssetTrigger();
var completion = scriptingCompleter.AssetTrigger();
return Ok(completion);
}

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

@ -35,6 +35,11 @@ public sealed class RuleTriggerDtoFactory : IRuleTriggerVisitor<RuleTriggerDto,
return CommentRuleTriggerDto.FromDomain(trigger);
}
public RuleTriggerDto Visit(CronJobTrigger trigger, None args)
{
return CronJobRuleTriggerDto.FromDomain(trigger);
}
public RuleTriggerDto Visit(ManualTrigger trigger, None args)
{
return ManualRuleTriggerDto.FromDomain(trigger);

10
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/CreateRuleDto.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Deprecated;
using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Flows.Internal;
@ -46,13 +45,6 @@ public sealed class CreateRuleDto
/// </summary>
public bool? IsEnabled { get; set; } = true;
public Rule ToRule()
{
#pragma warning disable CS0618 // Type or member is obsolete
return new Rule { Trigger = Trigger.ToTrigger(), Flow = GetFlow()! };
#pragma warning restore CS0618 // Type or member is obsolete
}
public CreateRule ToCommand()
{
return SimpleMapper.Map(this, new CreateRule
@ -66,7 +58,7 @@ public sealed class CreateRuleDto
}
[Obsolete("Has been replaced by flows.")]
private FlowDefinition? GetFlow()
public FlowDefinition? GetFlow()
{
return Flow?.ToDefinition() ?? Action?.ToFlowDefinition();
}

1
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/DynamicUpdateRuleDto.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Validation;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Rules.Models;

43
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/CronJobRuleTriggerDto.cs

@ -0,0 +1,43 @@
// ==========================================================================
// 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.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers;
public sealed class CronJobRuleTriggerDto : RuleTriggerDto
{
/// <summary>
/// The cron expression that defines the interval.
/// </summary>
[Required]
public string CronExpression { get; init; }
/// <summary>
/// The optional timezone.
/// </summary>
public string? CronTimezone { get; init; }
/// <summary>
/// The value sent to the flow.
/// </summary>
public JsonValue Value { get; init; }
public static CronJobRuleTriggerDto FromDomain(CronJobTrigger trigger)
{
return SimpleMapper.Map(trigger, new CronJobRuleTriggerDto());
}
public override RuleTrigger ToTrigger()
{
return SimpleMapper.Map(this, new CronJobTrigger());
}
}

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

@ -17,6 +17,7 @@ using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Domain.Apps.Entities.Rules.Runner;
using Squidex.Flows;
using Squidex.Flows.CronJobs;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Security;
@ -32,12 +33,14 @@ namespace Squidex.Areas.Api.Controllers.Rules;
[ApiExplorerSettings(GroupName = nameof(Rules))]
public sealed class RulesController(
ICommandBus commandBus,
ICronJobManager<CronJobContext> cronJobs,
IAppProvider appProvider,
IFlowStepRegistry flowStepRegistry,
IFlowManager<FlowEventContext> flowManager,
IRuleQueryService ruleQuery,
IRuleRunnerService ruleRunnerService,
IRuleValidator ruleValidator,
ScriptingCompleter scriptingCompleter,
EventJsonSchemaGenerator eventJsonSchemaGenerator)
: ApiController(commandBus)
{
@ -312,8 +315,18 @@ public sealed class RulesController(
[ApiCosts(5)]
public async Task<IActionResult> Simulate(string app, [FromBody] CreateRuleDto request)
{
var simulated = request.ToRule();
var simulation = await ruleRunnerService.SimulateAsync(App.NamedId(), DomainId.Empty, simulated, HttpContext.RequestAborted);
#pragma warning disable CS0618 // Type or member is obsolete
var flow = request.GetFlow();
#pragma warning restore CS0618 // Type or member is obsolete
if (flow == null)
{
return Ok(SimulatedRuleEventsDto.FromDomain([]));
}
var simulation =
await ruleRunnerService.SimulateAsync(App.NamedId(), request.Trigger.ToTrigger(), flow,
HttpContext.RequestAborted);
var response = SimulatedRuleEventsDto.FromDomain(simulation);
@ -495,14 +508,25 @@ public sealed class RulesController(
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
[ApiExplorerSettings(IgnoreApi = true)]
public IActionResult GetScriptCompletion(string app, string triggerType,
[FromServices] ScriptingCompleter completer)
public IActionResult GetScriptCompletion(string app, string triggerType)
{
var completion = completer.Trigger(triggerType);
var completion = scriptingCompleter.Trigger(triggerType);
return Ok(completion);
}
[HttpGet]
[Route("apps/{app}/rules/timezones")]
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
[ApiExplorerSettings(IgnoreApi = true)]
public IActionResult GetTimezones(string app)
{
var timezones = cronJobs.GetAvailableTimezoneIds();
return Ok(timezones);
}
private async Task<RuleDto> InvokeCommandAsync(ICommand command)
{
var context = await CommandBus.PublishAsync(command, HttpContext.RequestAborted);

27
backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs

@ -27,7 +27,12 @@ namespace Squidex.Areas.Api.Controllers.Schemas;
/// Update and query information about schemas.
/// </summary>
[ApiExplorerSettings(GroupName = nameof(Schemas))]
public sealed class SchemasController(ICommandBus commandBus, IAppProvider appProvider, IContentWorkflow workflow) : ApiController(commandBus)
public sealed class SchemasController(
ICommandBus commandBus,
IContentWorkflow workflow,
IAppProvider appProvider,
ScriptingCompleter scriptingCompleter)
: ApiController(commandBus)
{
/// <summary>
/// Get schemas.
@ -307,10 +312,9 @@ public sealed class SchemasController(ICommandBus commandBus, IAppProvider appPr
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> GetContentScriptsCompletion(string app, string schema,
[FromServices] ScriptingCompleter completer)
public async Task<IActionResult> GetContentScriptsCompletion(string app, string schema)
{
var completion = completer.ContentScript(await BuildModel());
var completion = scriptingCompleter.ContentScript(await BuildModel());
return Ok(completion);
}
@ -320,10 +324,9 @@ public sealed class SchemasController(ICommandBus commandBus, IAppProvider appPr
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> GetContentTriggersCompletion(string app, string schema,
[FromServices] ScriptingCompleter completer)
public async Task<IActionResult> GetContentTriggersCompletion(string app, string schema)
{
var completion = completer.ContentTrigger(await BuildModel());
var completion = scriptingCompleter.ContentTrigger(await BuildModel());
return Ok(completion);
}
@ -333,10 +336,9 @@ public sealed class SchemasController(ICommandBus commandBus, IAppProvider appPr
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> GetFieldRulesCompletion(string app, string schema,
[FromServices] ScriptingCompleter completer)
public async Task<IActionResult> GetFieldRulesCompletion(string app, string schema)
{
var completion = completer.FieldRule(await BuildModel());
var completion = scriptingCompleter.FieldRule(await BuildModel());
return Ok(completion);
}
@ -346,10 +348,9 @@ public sealed class SchemasController(ICommandBus commandBus, IAppProvider appPr
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> GetPreviewUrlsCompletion(string app, string schema,
[FromServices] ScriptingCompleter completer)
public async Task<IActionResult> GetPreviewUrlsCompletion(string app, string schema)
{
var completion = completer.PreviewUrl(await BuildModel());
var completion = scriptingCompleter.PreviewUrl(await BuildModel());
return Ok(completion);
}

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

@ -60,6 +60,9 @@ public static class RuleServices
services.AddSingletonAs<ManualTriggerHandler>()
.As<IRuleTriggerHandler>();
services.AddSingletonAs<CronJobTriggerHandler>()
.As<IRuleTriggerHandler>();
services.AddSingletonAs<SchemaChangedTriggerHandler>()
.As<IRuleTriggerHandler>();
@ -81,6 +84,9 @@ public static class RuleServices
services.AddSingletonAs<RuleEnqueuer>()
.As<IRuleEnqueuer>().As<IEventConsumer>();
services.AddSingletonAs<CronJobUpdater>()
.As<IEventConsumer>();
services.AddSingletonAs<EventJsonSchemaGenerator>()
.AsSelf();
@ -112,5 +118,9 @@ public static class RuleServices
.As<IFlowExecutionCallback<FlowEventContext>>();
services.AddFlows<FlowEventContext>(config);
services.AddCronJobs<CronJobContext>(config, m =>
{
m.UpdateInterval = TimeSpan.FromSeconds(1);
});
}
}

4
backend/src/Squidex/Config/Messaging/MessagingServices.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System.Text.Json;
using Squidex.AI;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Subscriptions;
using Squidex.Domain.Apps.Entities;
@ -71,6 +70,9 @@ public static class MessagingServices
services.AddFlowsCore()
.AddWorker<FlowEventContext>();
services.AddCronJobsCore()
.AddWorker<CronJobContext>();
}
if (isRandomName)

24
backend/src/Squidex/Squidex.csproj

@ -59,17 +59,17 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.4.1" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.S3" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.Azure" Version="7.15.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="7.15.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="7.15.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="7.15.0" />
<PackageReference Include="Squidex.Assets.S3" Version="7.15.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="7.15.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="21.5.0" />
<PackageReference Include="Squidex.Events.GetEventStore" Version="7.3.0" />
<PackageReference Include="Squidex.Hosting" Version="7.3.0" />
<PackageReference Include="Squidex.Messaging.All" Version="7.3.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.3.0" />
<PackageReference Include="Squidex.Events.GetEventStore" Version="7.15.0" />
<PackageReference Include="Squidex.Hosting" Version="7.15.0" />
<PackageReference Include="Squidex.Messaging.All" Version="7.15.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="7.15.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="YDotNet" Version="0.4.3" />
<PackageReference Include="YDotNet.Native" Version="0.4.3" />
@ -83,11 +83,11 @@
</ItemGroup>
<ItemGroup Condition="'$(IncludeMagick)' == 'true'">
<PackageReference Include="Squidex.Assets.ImageMagick" Version="7.3.0" />
<PackageReference Include="Squidex.Assets.ImageMagick" Version="7.15.0" />
</ItemGroup>
<ItemGroup Condition="'$(IncludeKafka)' == 'true'">
<PackageReference Include="Squidex.Messaging.Kafka" Version="7.3.0" />
<PackageReference Include="Squidex.Messaging.Kafka" Version="7.15.0" />
</ItemGroup>
<PropertyGroup>

1
backend/tests/Squidex.Data.Tests.CodeGenerator/CodeGenerator.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Diagnostics;
using System.Text;
using HandlebarsDotNet;
using Microsoft.CodeAnalysis;

1
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/EventJsonSchemaGeneratorTests.cs

@ -29,6 +29,7 @@ public class EventJsonSchemaGeneratorTests
yield return nameof(EnrichedAssetEvent);
yield return nameof(EnrichedCommentEvent);
yield return nameof(EnrichedContentEvent);
yield return nameof(EnrichedCronJobEvent);
yield return nameof(EnrichedManualEvent);
yield return nameof(EnrichedSchemaEvent);
yield return nameof(EnrichedUsageExceededEvent);

79
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/CronJobTriggerHandlerTests.cs

@ -0,0 +1,79 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Rules;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Rules;
public class CronJobTriggerHandlerTests
{
private readonly IRuleTriggerHandler sut = new CronJobTriggerHandler();
[Fact]
public void Should_return_false_if_asking_for_snapshot_support()
{
Assert.False(sut.CanCreateSnapshotEvents);
}
[Fact]
public void Should_calculate_name()
{
var @event = new RuleCronJobTriggered();
Assert.Equal("CronJob", sut.GetName(@event));
}
[Fact]
public async Task Should_create_event_with_name()
{
var @event = TestUtils.CreateEvent<RuleCronJobTriggered>();
var envelope = Envelope.Create<AppEvent>(@event);
var actual = await sut.CreateEnrichedEventsAsync(envelope, default, default).ToListAsync();
var enrichedEvent = (EnrichedCronJobEvent)actual.Single();
Assert.Equal(@event.Actor, enrichedEvent.Actor);
Assert.Equal(@event.AppId, enrichedEvent.AppId);
Assert.Equal(@event.AppId.Id, enrichedEvent.AppId.Id);
}
[Fact]
public async Task Should_create_event_with_actor()
{
var actor = RefToken.User("me");
var @event = new RuleCronJobTriggered { Actor = actor };
var envelope = Envelope.Create<AppEvent>(@event);
var actual = await sut.CreateEnrichedEventsAsync(envelope, default, default).ToListAsync();
Assert.Equal(actor, ((EnrichedUserEventBase)actual.Single()).Actor);
}
[Fact]
public void Should_always_trigger()
{
var @event = new RuleCronJobTriggered();
Assert.True(sut.Trigger(Envelope.Create<AppEvent>(@event), null!));
}
[Fact]
public void Should_always_trigger_enriched_event()
{
var @event = new EnrichedUsageExceededEvent();
Assert.True(sut.Trigger(@event, null!));
}
}

221
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/CronJobUpdaterTests.cs

@ -0,0 +1,221 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Domain.Apps.Events.Rules;
using Squidex.Events;
using Squidex.Flows.CronJobs;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Rules;
public sealed class CronJobUpdaterTests : GivenContext
{
private readonly ICronJobManager<CronJobContext> cronJobs = A.Fake<ICronJobManager<CronJobContext>>();
private readonly IRuleEnqueuer ruleEnqueuer = A.Fake<IRuleEnqueuer>();
private readonly CronJobUpdater sut;
public CronJobUpdaterTests()
{
sut = new CronJobUpdater(AppProvider, cronJobs, ruleEnqueuer);
}
[Fact]
public void Should_return_rules_filter_for_events_filter()
{
Assert.Equal(StreamFilter.Prefix("rule-"), sut.EventsFilter);
}
[Fact]
public async Task Should_register_handler_when_initialized()
{
await sut.InitializeAsync(default);
A.CallTo(() => cronJobs.Subscribe(A<Func<CronJob<CronJobContext>, CancellationToken, Task>>._))
.MustHaveHappened();
}
[Fact]
public async Task Should_register_new_cron_job()
{
var ruleId = DomainId.NewGuid();
var @event =
Envelope.Create(
new RuleCreated
{
AppId = AppId,
Trigger = new CronJobTrigger
{
CronExpression = "* */5 * * *",
CronTimezone = "Europe/Berlin",
},
RuleId = ruleId,
});
await sut.On(@event);
A.CallTo(() => cronJobs.AddAsync(
A<CronJob<CronJobContext>>.That.Matches(x =>
x.Id == ruleId.ToString() &&
x.Context.RuleId == ruleId &&
x.Context.AppId == AppId &&
x.CronExpression == "* */5 * * *" &&
x.CronTimezone == "Europe/Berlin"),
default))
.MustHaveHappened();
}
[Fact]
public async Task Should_register_updated_cron_job()
{
var ruleId = DomainId.NewGuid();
var @event =
Envelope.Create(
new RuleUpdated
{
AppId = AppId,
Trigger = new CronJobTrigger
{
CronExpression = "* */5 * * *",
CronTimezone = "Europe/Berlin",
},
RuleId = ruleId,
});
await sut.On(@event);
A.CallTo(() => cronJobs.AddAsync(
A<CronJob<CronJobContext>>.That.Matches(x =>
x.Id == ruleId.ToString() &&
x.Context.RuleId == ruleId &&
x.Context.AppId == AppId &&
x.CronExpression == "* */5 * * *" &&
x.CronTimezone == "Europe/Berlin"),
default))
.MustHaveHappened();
}
[Fact]
public async Task Should_unregister_when_trigger_changed()
{
var ruleId = DomainId.NewGuid();
var @event =
Envelope.Create(
new RuleUpdated
{
AppId = AppId,
Trigger = new ManualTrigger
{
},
RuleId = ruleId,
});
await sut.On(@event);
A.CallTo(() => cronJobs.RemoveAsync(ruleId.ToString(), default))
.MustHaveHappened();
}
[Fact]
public async Task Should_unregister_cron_job_when_rule_deleted()
{
var ruleId = DomainId.NewGuid();
var @event =
Envelope.Create(
new RuleDeleted
{
RuleId = ruleId,
});
await sut.On(@event);
A.CallTo(() => cronJobs.RemoveAsync(ruleId.ToString(), default))
.MustHaveHappened();
}
[Fact]
public async Task Should_do_nothing_when_trigger_not_changed()
{
var ruleId = DomainId.NewGuid();
var @event =
Envelope.Create(
new RuleUpdated
{
RuleId = ruleId,
});
await sut.On(@event);
A.CallTo(cronJobs)
.MustNotHaveHappened();
}
[Fact]
public async Task Should_call_rule_enqueue_when_cron_job_due()
{
var rule = CreateAndSetupRule(new CronJobTrigger());
var job = new CronJob<CronJobContext>
{
Id = rule.Id.ToString(),
CronExpression = "* */5 * * *",
CronTimezone = "Europe/Berlin",
Context = new CronJobContext(AppId, rule.Id),
};
await sut.HandleCronJobAsync(job, CancellationToken);
A.CallTo(() => ruleEnqueuer.EnqueueAsync(rule, A<Envelope<IEvent>>._, CancellationToken))
.MustHaveHappened();
}
[Fact]
public async Task Should_now_call_rule_enqueue_when_rule_has_no_cron_job_anymore()
{
var rule = CreateAndSetupRule();
var job = new CronJob<CronJobContext>
{
Id = rule.Id.ToString(),
CronExpression = "* */5 * * *",
CronTimezone = "Europe/Berlin",
Context = new CronJobContext(AppId, rule.Id),
};
await sut.HandleCronJobAsync(job, CancellationToken);
A.CallTo(ruleEnqueuer)
.MustNotHaveHappened();
}
[Fact]
public async Task Should_now_call_rule_enqueue_when_rule_is_not_found()
{
var rule = CreateAndSetupRule();
var job = new CronJob<CronJobContext>
{
Id = rule.Id.ToString(),
CronExpression = "* */5 * * *",
CronTimezone = "Europe/Berlin",
Context = new CronJobContext(AppId, rule.Id),
};
await sut.HandleCronJobAsync(job, CancellationToken);
A.CallTo(ruleEnqueuer)
.MustNotHaveHappened();
}
}

10
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/GuardRuleTests.cs

@ -12,6 +12,7 @@ using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Flows;
using Squidex.Flows.CronJobs;
using Squidex.Flows.Internal;
using Squidex.Infrastructure.Validation;
@ -20,11 +21,12 @@ namespace Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards;
public class GuardRuleTests : GivenContext, IClassFixture<TranslationsFixture>
{
private readonly IFlowManager<FlowEventContext> flowManager = A.Fake<IFlowManager<FlowEventContext>>();
private readonly ICronJobManager<CronJobContext> cronJobs = A.Fake<ICronJobManager<CronJobContext>>();
private readonly IRuleValidator validator;
public GuardRuleTests()
{
validator = new RuleValidator(flowManager, AppProvider);
validator = new RuleValidator(flowManager, cronJobs, AppProvider);
}
[Fact]
@ -82,7 +84,7 @@ public class GuardRuleTests : GivenContext, IClassFixture<TranslationsFixture>
{
var command = new UpdateRule();
await GuardRule.CanUpdate(command, CreateRule(), validator, CancellationToken);
await GuardRule.CanUpdate(command, validator, CancellationToken);
}
[Fact]
@ -90,7 +92,7 @@ public class GuardRuleTests : GivenContext, IClassFixture<TranslationsFixture>
{
var command = new UpdateRule { Name = "MyName" };
await GuardRule.CanUpdate(command, CreateRule(), validator, CancellationToken);
await GuardRule.CanUpdate(command, validator, CancellationToken);
}
[Fact]
@ -109,7 +111,7 @@ public class GuardRuleTests : GivenContext, IClassFixture<TranslationsFixture>
Name = "NewName",
});
await GuardRule.CanUpdate(command, CreateRule(), validator, CancellationToken);
await GuardRule.CanUpdate(command, validator, CancellationToken);
}
private T CreateCommand<T>(T command) where T : IAppCommand

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/ContentChangedTriggerTests.cs

@ -22,7 +22,7 @@ public class ContentChangedTriggerTests : GivenContext, IClassFixture<Translatio
public ContentChangedTriggerTests()
{
validator = new RuleValidator(null!, AppProvider);
validator = new RuleValidator(null!, null!, AppProvider);
}
[Fact]

89
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/CronJobTriggerValidationTests.cs

@ -0,0 +1,89 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Flows.CronJobs;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Entities.Rules.DomainObject.Guards.Triggers;
public class CronJobTriggerValidationTests : GivenContext, IClassFixture<TranslationsFixture>
{
private readonly ICronJobManager<CronJobContext> cronJobs = A.Fake<ICronJobManager<CronJobContext>>();
private readonly RuleValidator validator;
public CronJobTriggerValidationTests()
{
A.CallTo(() => cronJobs.IsValidCronExpression(A<string>._))
.Returns(true);
A.CallTo(() => cronJobs.IsValidTimezone(A<string>._))
.Returns(true);
validator = new RuleValidator(null!, cronJobs, AppProvider);
}
[Fact]
public async Task Should_add_error_if_expression_is_not_valid()
{
var trigger = new CronJobTrigger
{
CronExpression = "invalid",
};
A.CallTo(() => cronJobs.IsValidCronExpression("invalid"))
.Returns(false);
var errors = await ValidateAsync(trigger);
errors.Should().BeEquivalentTo(
[
new ValidationError("Cron Expression is invalid.", "CronExpression"),
]);
}
[Fact]
public async Task Should_add_error_if_timezone_is_not_valid()
{
var trigger = new CronJobTrigger
{
CronTimezone = "invalid",
};
A.CallTo(() => cronJobs.IsValidTimezone("invalid"))
.Returns(false);
var errors = await ValidateAsync(trigger);
errors.Should().BeEquivalentTo(
[
new ValidationError("Timezone is not a valid IANA identifier.", "CronTimezone"),
]);
}
[Fact]
public async Task Should_not_add_error_if_valid()
{
var trigger = new CronJobTrigger();
var errors = await ValidateAsync(trigger);
Assert.Empty(errors);
}
private async Task<List<ValidationError>> ValidateAsync(RuleTrigger trigger)
{
var errors = new List<ValidationError>();
await validator.ValidateTriggerAsync(trigger, AppId.Id, (m, p) => errors.Add(new ValidationError(m, p)), CancellationToken);
return errors;
}
}

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/Guards/Triggers/UsageTriggerValidationTests.cs

@ -19,7 +19,7 @@ public class UsageTriggerValidationTests : GivenContext, IClassFixture<Translati
public UsageTriggerValidationTests()
{
validator = new RuleValidator(null!, AppProvider);
validator = new RuleValidator(null!, null!, AppProvider);
}
[Fact]

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/DomainObject/RuleDomainObjectTests.cs

@ -157,8 +157,10 @@ public class RuleDomainObjectTests : HandlerTestBase<Rule>
await VerifySutAsync(actual, None.Value);
A.CallTo(() => ruleEnqueuer.EnqueueAsync(sut.Snapshot.Id, sut.Snapshot,
A<Envelope<IEvent>>.That.Matches(x => x.Payload is RuleManuallyTriggered)))
A.CallTo(() => ruleEnqueuer.EnqueueAsync(
sut.Snapshot,
A<Envelope<IEvent>>.That.Matches(x => x.Payload is RuleManuallyTriggered),
A<CancellationToken>._))
.MustHaveHappened();
}

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

@ -76,12 +76,12 @@ public class RuleEnqueuerTests : GivenContext
var rule = CreateAndSetupRule();
A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), default))
A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), CancellationToken))
.Returns(Enumerable.Repeat(new JobResult(), 1).ToAsyncEnumerable());
await sut.EnqueueAsync(rule.Id, rule, @event);
await sut.EnqueueAsync(rule, @event, CancellationToken);
A.CallTo(() => flowManager.EnqueueAsync(A<CreateFlowInstanceRequest<FlowEventContext>[]>._, default))
A.CallTo(() => flowManager.EnqueueAsync(A<CreateFlowInstanceRequest<FlowEventContext>[]>._, CancellationToken))
.MustNotHaveHappened();
}
@ -112,10 +112,10 @@ public class RuleEnqueuerTests : GivenContext
A.CallTo(() => flowManager.EnqueueAsync(A<CreateFlowInstanceRequest<FlowEventContext>[]>._, default))
.Invokes(x => writes = x.GetArgument<CreateFlowInstanceRequest<FlowEventContext>[]>(0)!);
A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), default))
A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), CancellationToken))
.Returns(new[] { result }.ToAsyncEnumerable());
await sut.EnqueueAsync(rule.Id, rule, @event);
await sut.EnqueueAsync(rule, @event, CancellationToken);
Assert.Equal(new[] { result.Job.Value }, writes);
@ -144,15 +144,15 @@ public class RuleEnqueuerTests : GivenContext
},
};
A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), default))
A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), CancellationToken))
.Returns(new[] { result }.ToAsyncEnumerable());
await sut.EnqueueAsync(rule.Id, rule, @event);
await sut.EnqueueAsync(rule, @event, CancellationToken);
A.CallTo(ruleUsageTracker)
.MustNotHaveHappened();
A.CallTo(() => flowManager.EnqueueAsync(A<CreateFlowInstanceRequest<FlowEventContext>[]>._, default))
A.CallTo(() => flowManager.EnqueueAsync(A<CreateFlowInstanceRequest<FlowEventContext>[]>._, CancellationToken))
.MustNotHaveHappened();
}
@ -171,15 +171,15 @@ public class RuleEnqueuerTests : GivenContext
Rule = rule,
};
A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), default))
A.CallTo(() => ruleService.CreateJobsAsync(@event, MatchingContext(rule), CancellationToken))
.Returns(new[] { result }.ToAsyncEnumerable());
await sut.EnqueueAsync(rule.Id, rule, @event);
await sut.EnqueueAsync(rule, @event, CancellationToken);
A.CallTo(ruleUsageTracker)
.MustNotHaveHappened();
A.CallTo(() => flowManager.EnqueueAsync(A<CreateFlowInstanceRequest<FlowEventContext>[]>._, default))
A.CallTo(() => flowManager.EnqueueAsync(A<CreateFlowInstanceRequest<FlowEventContext>[]>._, CancellationToken))
.MustNotHaveHappened();
}

80
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleFlowTrackingCallbackTests.cs

@ -0,0 +1,80 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Flows.Internal.Execution;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Rules;
public class RuleFlowTrackingCallbackTests : GivenContext
{
private readonly IRuleUsageTracker ruleUsageTracker = A.Fake<IRuleUsageTracker>();
private readonly RuleFlowTrackingCallback sut;
public RuleFlowTrackingCallbackTests()
{
sut = new RuleFlowTrackingCallback(ruleUsageTracker);
}
[Fact]
public async Task Should_track_usage_with_success()
{
var ruleId = DomainId.NewGuid();
await sut.OnUpdateAsync(
new FlowExecutionState<FlowEventContext>
{
InstanceId = default,
Context = new FlowEventContext(),
Definition = null!,
DefinitionId = ruleId.ToString(),
OwnerId = AppId.Id.ToString(),
Status = FlowExecutionStatus.Completed,
},
CancellationToken);
A.CallTo(() => ruleUsageTracker.TrackAsync(
AppId.Id,
ruleId,
A<DateOnly>._,
0,
1,
0,
CancellationToken))
.MustHaveHappened();
}
[Fact]
public async Task Should_track_usage_with_failure()
{
var ruleId = DomainId.NewGuid();
await sut.OnUpdateAsync(
new FlowExecutionState<FlowEventContext>
{
InstanceId = default,
Context = new FlowEventContext(),
Definition = null!,
DefinitionId = ruleId.ToString(),
OwnerId = AppId.Id.ToString(),
Status = FlowExecutionStatus.Failed,
},
CancellationToken);
A.CallTo(() => ruleUsageTracker.TrackAsync(
AppId.Id,
ruleId,
A<DateOnly>._,
0,
0,
1,
CancellationToken))
.MustHaveHappened();
}
}

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleValidatorTests.cs

@ -10,6 +10,7 @@ using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Flows;
using Squidex.Flows.CronJobs;
using Squidex.Flows.Internal;
using Squidex.Infrastructure.Validation;
@ -18,6 +19,7 @@ namespace Squidex.Domain.Apps.Entities.Rules;
public class RuleValidatorTests : GivenContext, IClassFixture<TranslationsFixture>
{
private readonly IFlowManager<FlowEventContext> flowManager = A.Fake<IFlowManager<FlowEventContext>>();
private readonly ICronJobManager<CronJobContext> cronJobs = A.Fake<ICronJobManager<CronJobContext>>();
private readonly RuleValidator sut;
private sealed record TestFlowStep : FlowStep
@ -31,7 +33,7 @@ public class RuleValidatorTests : GivenContext, IClassFixture<TranslationsFixtur
public RuleValidatorTests()
{
sut = new RuleValidator(flowManager, AppProvider);
sut = new RuleValidator(flowManager, cronJobs, AppProvider);
}
[Fact]

9
backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/GivenContext.cs

@ -9,6 +9,7 @@ using NodaTime;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.Teams;
@ -169,9 +170,9 @@ public class GivenContext
return result;
}
public EnrichedRule CreateAndSetupRule()
public EnrichedRule CreateAndSetupRule(RuleTrigger? trigger = null)
{
var rule = CreateRule();
var rule = CreateRule(trigger);
A.CallTo(() => AppProvider.GetRulesAsync(AppId.Id, A<CancellationToken>._))
.Returns([rule]);
@ -226,7 +227,7 @@ public class GivenContext
};
}
public EnrichedRule CreateRule()
public EnrichedRule CreateRule(RuleTrigger? trigger = null)
{
var id = DomainId.NewGuid();
@ -240,7 +241,7 @@ public class GivenContext
Name = "My Rule",
LastModified = Timestamp(),
LastModifiedBy = User,
Trigger = new ContentChangedTriggerV2(),
Trigger = trigger ?? new ContentChangedTriggerV2(),
Version = 1,
};
}

127
backend/tests/Squidex.Infrastructure.Tests/Http/DumpFormatterTests.cs

@ -1,127 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Net;
using System.Net.Http.Headers;
using System.Text;
#pragma warning disable SA1122 // Use string.Empty for empty strings
namespace Squidex.Infrastructure.Http;
public class DumpFormatterTests
{
[Fact]
public void Should_format_dump_without_response()
{
var httpRequest = CreateRequest();
var dump = DumpFormatter.BuildDump(httpRequest, null, null, null, TimeSpan.FromMinutes(1), true);
var expected = CreateExpectedDump(
"Request:",
"POST: https://cloud.squidex.io/ HTTP/1.1",
"User-Agent: Squidex/1.0",
"Accept-Language: de; en",
"Accept-Encoding: UTF-8",
"",
"",
"Response:",
"Timeout after 00:01:00");
Assert.Equal(expected, dump);
}
[Fact]
public void Should_format_dump_without_content()
{
var httpRequest = CreateRequest();
var httpResponse = CreateResponse();
var dump = DumpFormatter.BuildDump(httpRequest, httpResponse, null, null, TimeSpan.FromMinutes(1), false);
var expected = CreateExpectedDump(
"Request:",
"POST: https://cloud.squidex.io/ HTTP/1.1",
"User-Agent: Squidex/1.0",
"Accept-Language: de; en",
"Accept-Encoding: UTF-8",
"",
"",
"Response:",
"HTTP/1.1 200 OK",
"Transfer-Encoding: UTF-8",
"Trailer: Expires",
"",
"Elapsed: 00:01:00");
Assert.Equal(expected, dump);
}
[Fact]
public void Should_format_dump_with_content_without_timeout()
{
var httpRequest = CreateRequest(new StringContent("Hello Squidex", Encoding.UTF8, "text/plain"));
var httpResponse = CreateResponse(new StringContent("Hello Back", Encoding.UTF8, "text/plain"));
var dump = DumpFormatter.BuildDump(httpRequest, httpResponse, "Hello Squidex", "Hello Back", TimeSpan.FromMinutes(1), false);
var expected = CreateExpectedDump(
"Request:",
"POST: https://cloud.squidex.io/ HTTP/1.1",
"User-Agent: Squidex/1.0",
"Accept-Language: de; en",
"Accept-Encoding: UTF-8",
"Content-Type: text/plain; charset=utf-8",
"",
"Hello Squidex",
"",
"",
"Response:",
"HTTP/1.1 200 OK",
"Transfer-Encoding: UTF-8",
"Trailer: Expires",
"Content-Type: text/plain; charset=utf-8",
"",
"Hello Back",
"",
"Elapsed: 00:01:00");
Assert.Equal(expected, dump);
}
private static HttpRequestMessage CreateRequest(HttpContent? content = null)
{
var request = new HttpRequestMessage(HttpMethod.Post, new Uri("https://cloud.squidex.io"));
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("Squidex", "1.0"));
request.Headers.AcceptLanguage.Add(new StringWithQualityHeaderValue("de"));
request.Headers.AcceptLanguage.Add(new StringWithQualityHeaderValue("en"));
request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("UTF-8"));
request.Content = content;
return request;
}
private static HttpResponseMessage CreateResponse(HttpContent? content = null)
{
var response = new HttpResponseMessage(HttpStatusCode.OK);
response.Headers.TransferEncoding.Add(new TransferCodingHeaderValue("UTF-8"));
response.Headers.Trailer.Add("Expires");
response.Content = content;
return response;
}
private static string CreateExpectedDump(params string[] input)
{
return string.Join(Environment.NewLine, input) + Environment.NewLine;
}
}

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

@ -39,7 +39,8 @@
<div class="form-group form-group-section">
<div class="form-group">
<label for="password">{{ "common.password" | sqxTranslate }}</label> <sqx-control-errors for="password" />
<label for="password">{{ "common.password" | sqxTranslate }}</label>
<sqx-control-errors for="password" />
<input class="form-control" id="password" autocomplete="off" formControlName="password" maxlength="100" type="password" />
</div>
@ -51,7 +52,8 @@
</div>
<div class="form-group form-group-section">
<label for="permissions">{{ "common.permissions" | sqxTranslate }}</label> <sqx-control-errors for="permissions" />
<label for="permissions">{{ "common.permissions" | sqxTranslate }}</label>
<sqx-control-errors for="permissions" />
<textarea
class="form-control"
id="permissions"

96
frontend/src/app/features/rules/pages/events/rule-event.stories.ts

@ -6,7 +6,7 @@
*/
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
import { DateTime, DynamicFlowDefinitionDto, DynamicFlowStepDefinitionDto, FlowExecutionStateDto, FlowExecutionStepAttemptDto, FlowExecutionStepLogEntryDto, FlowExecutionStepStateDto, LocalizerService, ResourceLinkDto, RuleElementDto, RuleEventDto } from '@app/shared';
import { DateTime, DynamicFlowDefinitionDto, DynamicFlowStepDefinitionDto, FlowExecutionStateDto, FlowExecutionStepAttemptDto, FlowExecutionStepLogEntryDto, FlowExecutionStepStateDto, LocalizerService, ResourceLinkDto, RuleElementDto, RuleElementPropertyDto, RuleEventDto } from '@app/shared';
import { RuleEventComponent } from './rule-event.component';
export default {
@ -39,10 +39,12 @@ export default {
'common.details': 'Details',
'common.error': 'Error',
'common.event': 'Event',
'common.output': 'Output',
'common.properties': 'Properties',
'common.started': 'Created',
'rules.ruleEvents.enqueue': 'Enqueue',
'rules.ruleEvents.attempt': 'Attempt',
'rules.ruleEvents.attempts': 'Attempts',
'rules.ruleEvents.enqueue': 'Enqueue',
'rules.ruleEvents.nextAttemptLabel': 'Next',
}),
},
@ -69,7 +71,36 @@ const AVAILABLE_STEPS = {
iconColor: '#4bb958',
iconImage: '<svg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 28 28\'><path d=\'M5.95 27.125h-.262C1.75 26.425 0 23.187 0 20.3c0-2.713 1.575-5.688 5.075-6.563V9.712c0-.525.35-.875.875-.875s.875.35.875.875v4.725c0 .438-.35.787-.7.875-2.975.438-4.375 2.8-4.375 4.988s1.313 4.55 4.2 5.075h.175a.907.907 0 0 1 .7 1.05c-.088.438-.438.7-.875.7zM21.175 27.387c-2.8 0-5.775-1.662-6.65-5.075H9.712c-.525 0-.875-.35-.875-.875s.35-.875.875-.875h5.512c.438 0 .787.35.875.7.438 2.975 2.8 4.288 4.988 4.375 2.188 0 4.55-1.313 5.075-4.2v-.088a.908.908 0 0 1 1.05-.7.908.908 0 0 1 .7 1.05v.088c-.612 3.85-3.85 5.6-6.737 5.6zM21.525 18.55c-.525 0-.875-.35-.875-.875v-4.813c0-.438.35-.787.7-.875 2.975-.438 4.288-2.8 4.375-4.987 0-2.188-1.313-4.55-4.2-5.075h-.088c-.525-.175-.875-.613-.787-1.05s.525-.788 1.05-.7h.088c3.938.7 5.688 3.937 5.688 6.825 0 2.713-1.662 5.688-5.075 6.563v4.113c0 .438-.438.875-.875.875zM1.137 6.737H.962c-.438-.087-.788-.525-.7-.963v-.087c.7-3.938 3.85-5.688 6.737-5.688h.087c2.712 0 5.688 1.662 6.563 5.075h4.025c.525 0 .875.35.875.875s-.35.875-.875.875h-4.725c-.438 0-.788-.35-.875-.7-.438-2.975-2.8-4.288-4.988-4.375-2.188 0-4.55 1.313-5.075 4.2v.087c-.088.438-.438.7-.875.7z\'/><path d=\'M7 10.588c-.875 0-1.837-.35-2.538-1.05a3.591 3.591 0 0 1 0-5.075C5.162 3.851 6.037 3.5 7 3.5s1.838.35 2.537 1.05c.7.7 1.05 1.575 1.05 2.537s-.35 1.837-1.05 2.538c-.7.612-1.575.963-2.537.963zM7 5.25c-.438 0-.875.175-1.225.525a1.795 1.795 0 0 0 2.538 2.538c.35-.35.525-.788.525-1.313s-.175-.875-.525-1.225S7.525 5.25 7 5.25zM21.088 23.887a3.65 3.65 0 0 1-2.537-1.05 3.591 3.591 0 0 1 0-5.075c.7-.7 1.575-1.05 2.537-1.05s1.838.35 2.537 1.05c.7.7 1.05 1.575 1.05 2.538s-.35 1.837-1.05 2.537c-.787.7-1.662 1.05-2.537 1.05zm0-5.337c-.525 0-.963.175-1.313.525a1.795 1.795 0 0 0 2.537 2.538c.35-.35.525-.788.525-1.313s-.175-.963-.525-1.313-.787-.438-1.225-.438zM20.387 10.588c-.875 0-1.837-.35-2.537-1.05S16.8 7.963 16.8 7.001s.35-1.837 1.05-2.538c.7-.612 1.662-.962 2.537-.962s1.838.35 2.538 1.05c1.4 1.4 1.4 3.675 0 5.075-.7.612-1.575.963-2.538.963zm0-5.338c-.525 0-.962.175-1.313.525s-.525.788-.525 1.313.175.962.525 1.313c.7.7 1.838.7 2.538 0s.7-1.838 0-2.538c-.263-.438-.7-.612-1.225-.612zM7.087 23.887c-.875 0-1.837-.35-2.538-1.05s-1.05-1.575-1.05-2.537.35-1.838 1.05-2.538c.7-.612 1.575-.962 2.538-.962s1.837.35 2.538 1.05c1.4 1.4 1.4 3.675 0 5.075-.7.612-1.575.962-2.538.962zm0-5.337c-.525 0-.962.175-1.313.525s-.525.788-.525 1.313.175.963.525 1.313a1.794 1.794 0 1 0 2.538-2.537c-.263-.438-.7-.612-1.225-.612z\'/></svg>',
readMore: 'https://en.wikipedia.org/wiki/Webhook',
properties: [],
properties: [
new RuleElementPropertyDto({
name: 'httpMethod',
display: 'Method',
editor: 'Input',
isFormattable: true,
isRequired: true,
}),
new RuleElementPropertyDto({
name: 'httpBody',
display: 'Body',
editor: 'Input',
isFormattable: true,
isRequired: true,
}),
new RuleElementPropertyDto({
name: 'headers',
display: 'Headers',
editor: 'Input',
isFormattable: true,
isRequired: true,
}),
new RuleElementPropertyDto({
name: 'retries',
display: 'Retries',
editor: 'number',
isFormattable: true,
isRequired: true,
}),
],
}),
};
@ -81,7 +112,13 @@ const DEFINITION = new DynamicFlowDefinitionDto({
nextStepId: '2',
}),
'2': new DynamicFlowStepDefinitionDto({
step: { stepType: 'Webhook' },
step: {
stepType: 'Webhook',
httpMethod: 'GET',
httpBody: JSON.stringify({ request: 'Hello World' }, undefined, 2),
headers: 'Header1: Value1\nHeader2: Value2',
retries: 2,
},
nextStepId: null!,
}),
},
@ -119,12 +156,10 @@ export const Completed: Story = {
log: [],
}),
],
isPrepared: true,
}),
'2': new FlowExecutionStepStateDto({
status: 'Completed',
attempts: [],
isPrepared: true,
}),
},
}),
@ -166,12 +201,10 @@ export const Failed: Story = {
error: 'Something failed',
}),
],
isPrepared: true,
}),
'2': new FlowExecutionStepStateDto({
status: 'Failed',
attempts: [],
isPrepared: true,
}),
},
}),
@ -220,12 +253,55 @@ export const Logs: Story = {
error: 'Something failed',
}),
],
isPrepared: true,
}),
'2': new FlowExecutionStepStateDto({
status: 'Failed',
attempts: [],
isPrepared: true,
}),
},
}),
_links: {},
}),
availableSteps: AVAILABLE_STEPS,
},
};
export const Properties: Story = {
args: {
expanded: true,
event: new RuleEventDto({
id: '1',
flowState: new FlowExecutionStateDto({
context: {
type: 'AssetCreated',
},
completed: now.addHours(1),
created: now,
definition: DEFINITION,
description: 'AssetCreated',
nextStepId: null!,
status: 'Failed',
steps: {
'1': new FlowExecutionStepStateDto({
status: 'Failed',
attempts: [],
}),
'2': new FlowExecutionStepStateDto({
status: 'Failed',
attempts: [
new FlowExecutionStepAttemptDto({
completed: now,
started: now,
log: [],
error: 'Something failed',
}),
new FlowExecutionStepAttemptDto({
completed: now,
started: now,
log: [],
error: 'Something failed',
}),
],
}),
},
}),

18
frontend/src/app/features/rules/pages/rule/step-dialog.component.html

@ -20,7 +20,12 @@
<div class="col-9 offset-3">
<div class="form-check">
<input class="form-check-input" id="stepIgnore" [(ngModel)]="stepIgnoreError" [ngModelOptions]="{ standalone: true }" type="checkbox" />
<input
class="form-check-input"
id="stepIgnore"
[(ngModel)]="stepIgnoreError"
[ngModelOptions]="{ standalone: true }"
type="checkbox" />
<label class="form-check-label" for="stepIgnore"> {{ "rules.stepIgnoreError" | sqxTranslate }} </label>
</div>
<sqx-form-hint> {{ "rules.stepIgnoreErrorHint" | sqxTranslate }} </sqx-form-hint>
@ -130,7 +135,16 @@
<button class="btn btn-text-secondary" (click)="dialogClose.emit()" type="button">
{{ "common.cancel" | sqxTranslate }}
</button>
<button class="btn btn-success" [disabled]="!currentStep" type="submit">{{ "common.save" | sqxTranslate }}</button>
@if (stepDefinition) {
<button class="btn btn-primary" [disabled]="!currentStep" type="submit">
{{ "common.save" | sqxTranslate }}
</button>
} @else {
<button class="btn btn-success" [disabled]="!currentStep" type="submit">
{{ "common.add" | sqxTranslate }}
</button>
}
</ng-container>
</sqx-modal-dialog>
</form>

15
frontend/src/app/features/rules/pages/rule/trigger-dialog.component.html

@ -27,6 +27,10 @@
[triggerForm]="currentTrigger" />
}
@case ("CronJob") {
<sqx-cron-job-trigger [triggerForm]="currentTrigger" />
}
@case ("SchemaChanged") {
<sqx-schema-changed-trigger [triggerForm]="currentTrigger" />
}
@ -55,7 +59,16 @@
<button class="btn btn-text-secondary" (click)="dialogClose.emit()" type="button">
{{ "common.cancel" | sqxTranslate }}
</button>
<button class="btn btn-success" [disabled]="!currentTrigger" type="submit">{{ "common.save" | sqxTranslate }}</button>
@if (trigger) {
<button class="btn btn-primary" [disabled]="!currentTrigger" type="submit">
{{ "common.save" | sqxTranslate }}
</button>
} @else {
<button class="btn btn-success" [disabled]="!currentTrigger" type="submit">
{{ "common.add" | sqxTranslate }}
</button>
}
</ng-container>
</sqx-modal-dialog>
</form>

2
frontend/src/app/features/rules/pages/rule/trigger-dialog.component.ts

@ -13,6 +13,7 @@ import { RuleElementComponent } from '../../shared/rule-element.component';
import { AssetChangedTriggerComponent } from '../../shared/triggers/asset-changed-trigger.component';
import { CommentTriggerComponent } from '../../shared/triggers/comment-trigger.component';
import { ContentChangedTriggerComponent } from '../../shared/triggers/content-changed-trigger.component';
import { CronJobTriggerComponent } from '../../shared/triggers/cron-job-trigger.component';
import { SchemaChangedTriggerComponent } from '../../shared/triggers/schema-changed-trigger.component';
import { UsageTriggerComponent } from '../../shared/triggers/usage-trigger.component';
@ -26,6 +27,7 @@ import { UsageTriggerComponent } from '../../shared/triggers/usage-trigger.compo
AsyncPipe,
CommentTriggerComponent,
ContentChangedTriggerComponent,
CronJobTriggerComponent,
EntriesPipe,
FormErrorComponent,
ModalDialogComponent,

8
frontend/src/app/features/rules/pages/rules/rule.component.html

@ -147,11 +147,9 @@
@if (rule.canReadLogs) {
<div class="col-auto">
<a [queryParams]="{ ruleId: rule.id }" routerLink="events"> {{ "common.logs" | sqxTranslate }} </a>
@if (!rule.canTrigger) {
<a class="ms-2" [queryParams]="{ ruleId: rule.id }" routerLink="simulator">
{{ "rules.simulator" | sqxTranslate }}
</a>
}
<a class="ms-2" [queryParams]="{ ruleId: rule.id }" routerLink="simulator">
{{ "rules.simulator" | sqxTranslate }}
</a>
</div>
}
</div>

4
frontend/src/app/features/rules/pages/simulator/simulated-rule-event.component.html

@ -29,7 +29,7 @@
<div class="event-dump event-section">
@if (event.event) {
<sqx-history-step isDefaultExpandable="true" isExpandable="true">
<sqx-history-step isExpandable="true">
<ng-container summary>
<div class="col text-sm">
{{ "rules.simulation.eventQueried" | sqxTranslate }}
@ -47,7 +47,7 @@
text="i18n:rules.simulation.eventTriggerChecked" />
@if (event.enrichedEvent) {
<sqx-history-step isDefaultExpandable="true" isExpandable="true">
<sqx-history-step isExpandable="true">
<ng-container summary>
<div class="col text-sm">
{{ "rules.simulation.eventEnriched" | sqxTranslate }}

2
frontend/src/app/features/rules/pages/simulator/simulated-rule-event.stories.ts

@ -143,12 +143,10 @@ export const Default: Story = {
log: [],
}),
],
isPrepared: true,
}),
'2': new FlowExecutionStepStateDto({
status: 'Completed',
attempts: [],
isPrepared: true,
}),
},
}),

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

Loading…
Cancel
Save