diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs index 3f776c24b..fd298d5ef 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs @@ -6,7 +6,6 @@ // ========================================================================== using System; -using System.Collections.Generic; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Core.Contents diff --git a/src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs new file mode 100644 index 000000000..9e3900deb --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.Contents +{ + public enum StatusChange + { + Archived, + Published, + Restored, + Unpublished + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/MediumAction.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/MediumAction.cs new file mode 100644 index 000000000..406bff8a9 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Actions/MediumAction.cs @@ -0,0 +1,32 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Rules.Actions +{ + [TypeName(nameof(MediumAction))] + public sealed class MediumAction : RuleAction + { + public string AccessToken { get; set; } + + public string Tags { get; set; } + + public string Title { get; set; } + + public string CanonicalUrl { get; set; } + + public string Content { get; set; } + + public bool IsHtml { get; set; } + + public override T Accept(IRuleActionVisitor visitor) + { + return visitor.Visit(this); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleActionVisitor.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleActionVisitor.cs index 2ef2e3516..dc1929cb7 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleActionVisitor.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/IRuleActionVisitor.cs @@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Core.Rules T Visit(FastlyAction action); + T Visit(MediumAction action); + T Visit(SlackAction action); T Visit(WebhookAction action); diff --git a/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs b/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs index 33d073f5a..cfcb516d1 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs @@ -20,5 +20,11 @@ namespace Squidex.Domain.Apps.Core.Rules.Triggers public bool SendDelete { get; set; } public bool SendPublish { get; set; } + + public bool SendUnpublish { get; set; } + + public bool SendArchived { get; set; } + + public bool SendRestore { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs index 006488a2b..e1546841d 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs @@ -9,7 +9,6 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics.Contracts; -using System.Globalization; using System.Linq; using Squidex.Infrastructure; diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs index cec57ddae..12d0bf225 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs @@ -10,12 +10,9 @@ using System.Threading.Tasks; using Algolia.Search; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Domain.Apps.Events; -using Squidex.Domain.Apps.Events.Contents; using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; #pragma warning disable SA1649 // File name must match first type name @@ -24,6 +21,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions public sealed class AlgoliaJob { public string AppId { get; set; } + public string ApiKey { get; set; } public string ContentId { get; set; } @@ -54,11 +52,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions }); } - protected override async Task<(string Description, AlgoliaJob Data)> CreateJobAsync(Envelope @event, string eventName, AlgoliaAction action) + protected override (string Description, AlgoliaJob Data) CreateJob(EnrichedEvent @event, AlgoliaAction action) { - if (@event.Payload is ContentEvent contentEvent) + if (@event is EnrichedContentEvent contentEvent) { - var contentId = contentEvent.ContentId.ToString(); + var contentId = contentEvent.Id.ToString(); var ruleDescription = string.Empty; var ruleJob = new AlgoliaJob @@ -66,59 +64,20 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions AppId = action.AppId, ApiKey = action.ApiKey, ContentId = contentId, - IndexName = await formatter.FormatStringAsync(action.IndexName, @event) + IndexName = formatter.Format(action.IndexName, @event) }; - var timestamp = @event.Headers.Timestamp().ToString(); - - switch (@event.Payload) + if (contentEvent.Type == EnrichedContentEventType.Deleted || + contentEvent.Type == EnrichedContentEventType.Unpublished) + { + ruleDescription = $"Delete entry from Algolia index: {action.IndexName}"; + } + else { - case ContentCreated created: - { - ruleDescription = $"Add entry to Algolia index: {action.IndexName}"; - - ruleJob.Content = new JObject( - new JProperty("objectID", contentId), - new JProperty("id", contentId), - new JProperty("created", timestamp), - new JProperty("createdBy", created.Actor.ToString()), - new JProperty("lastModified", timestamp), - new JProperty("lastModifiedBy", created.Actor.ToString()), - new JProperty("status", Status.Draft.ToString()), - new JProperty("data", formatter.ToRouteData(created.Data))); - break; - } - - case ContentUpdated updated: - { - ruleDescription = $"Update entry in Algolia index: {action.IndexName}"; - - ruleJob.Content = new JObject( - new JProperty("objectID", contentId), - new JProperty("lastModified", timestamp), - new JProperty("lastModifiedBy", updated.Actor.ToString()), - new JProperty("data", formatter.ToRouteData(updated.Data))); - break; - } - - case ContentStatusChanged statusChanged: - { - ruleDescription = $"Update entry in Algolia index: {action.IndexName}"; - - ruleJob.Content = new JObject( - new JProperty("objectID", contentId), - new JProperty("lastModified", timestamp), - new JProperty("lastModifiedBy", statusChanged.Actor.ToString()), - new JProperty("status", statusChanged.Status.ToString())); - break; - } - - case ContentDeleted deleted: - { - ruleDescription = $"Delete entry from Algolia index: {action.IndexName}"; - - break; - } + ruleDescription = $"Add entry to Algolia index: {action.IndexName}"; + + ruleJob.Content = formatter.ToPayload(contentEvent); + ruleJob.Content["objectID"] = contentId; } return (ruleDescription, ruleJob); diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs index 2b0e50b91..72af2b839 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs @@ -11,10 +11,9 @@ using Microsoft.WindowsAzure.Storage; using Microsoft.WindowsAzure.Storage.Queue; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Domain.Apps.Events; using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; #pragma warning disable SA1649 // File name must match first type name @@ -23,6 +22,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions public sealed class AzureQueueJob { public string QueueConnectionString { get; set; } + public string QueueName { get; set; } public string MessageBodyV2 { get; set; } @@ -60,11 +60,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions }); } - protected override async Task<(string Description, AzureQueueJob Data)> CreateJobAsync(Envelope @event, string eventName, AzureQueueAction action) + protected override (string Description, AzureQueueJob Data) CreateJob(EnrichedEvent @event, AzureQueueAction action) { - var body = formatter.ToRouteData(@event, eventName).ToString(Formatting.Indented); + var body = formatter.ToEnvelope(@event).ToString(Formatting.Indented); - var queueName = await formatter.FormatStringAsync(action.Queue, @event); + var queueName = formatter.Format(action.Queue, @event); var ruleDescription = $"Send AzureQueueJob to azure queue '{action.Queue}'"; var ruleJob = new AzureQueueJob diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs index 94df18789..b51b2d5bf 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs @@ -9,12 +9,9 @@ using System; using System.Threading.Tasks; using Elasticsearch.Net; using Newtonsoft.Json.Linq; -using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Domain.Apps.Events; -using Squidex.Domain.Apps.Events.Contents; using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; #pragma warning disable SA1649 // File name must match first type name @@ -25,14 +22,14 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions public string Host { get; set; } public string Username { get; set; } + public string Password { get; set; } public string ContentId { get; set; } public string IndexName { get; set; } - public string IndexType { get; set; } - public string Operation { get; set; } + public string IndexType { get; set; } public JObject Content { get; set; } } @@ -63,11 +60,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions }); } - protected override async Task<(string Description, ElasticSearchJob Data)> CreateJobAsync(Envelope @event, string eventName, ElasticSearchAction action) + protected override (string Description, ElasticSearchJob Data) CreateJob(EnrichedEvent @event, ElasticSearchAction action) { - if (@event.Payload is ContentEvent contentEvent) + if (@event is EnrichedContentEvent contentEvent) { - var contentId = contentEvent.ContentId.ToString(); + var contentId = contentEvent.Id.ToString(); var ruleDescription = string.Empty; var ruleJob = new ElasticSearchJob @@ -76,63 +73,21 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions Username = action.Username, Password = action.Password, ContentId = contentId, - IndexName = await formatter.FormatStringAsync(action.IndexName, @event), - IndexType = await formatter.FormatStringAsync(action.IndexType, @event), + IndexName = formatter.Format(action.IndexName, @event), + IndexType = formatter.Format(action.IndexType, @event), }; - var timestamp = @event.Headers.Timestamp().ToString(); - - var actor = @event.Payload.Actor.ToString(); - - switch (@event.Payload) + if (contentEvent.Type == EnrichedContentEventType.Deleted || + contentEvent.Type == EnrichedContentEventType.Unpublished) { - case ContentCreated created: - { - ruleDescription = $"Add entry to ES index: {action.IndexName}"; - - ruleJob.Operation = "Create"; - ruleJob.Content = new JObject( - new JProperty("id", contentId), - new JProperty("created", timestamp), - new JProperty("createdBy", actor), - new JProperty("lastModified", timestamp), - new JProperty("lastModifiedBy", actor), - new JProperty("status", Status.Draft.ToString()), - new JProperty("data", formatter.ToRouteData(created.Data))); - break; - } - - case ContentUpdated updated: - { - ruleDescription = $"Update entry in ES index: {action.IndexName}"; - - ruleJob.Operation = "Update"; - ruleJob.Content = new JObject( - new JProperty("lastModified", timestamp), - new JProperty("lastModifiedBy", actor), - new JProperty("data", formatter.ToRouteData(updated.Data))); - break; - } - - case ContentStatusChanged statusChanged: - { - ruleDescription = $"Update entry in ES index: {action.IndexName}"; - - ruleJob.Operation = "Update"; - ruleJob.Content = new JObject( - new JProperty("lastModified", timestamp), - new JProperty("lastModifiedBy", actor), - new JProperty("status", statusChanged.Status.ToString())); - break; - } - - case ContentDeleted deleted: - { - ruleDescription = $"Delete entry from ES index: {action.IndexName}"; - - ruleJob.Operation = "Delete"; - break; - } + ruleDescription = $"Delete entry index: {action.IndexName}"; + } + else + { + ruleDescription = $"Upsert to index: {action.IndexName}"; + + ruleJob.Content = formatter.ToPayload(contentEvent); + ruleJob.Content["objectID"] = contentId; } } @@ -141,44 +96,23 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(ElasticSearchJob job) { - if (string.IsNullOrWhiteSpace(job.Operation)) - { - return (null, new InvalidOperationException("The action cannot handle this event.")); - } - var client = clients.GetClient((new Uri(job.Host, UriKind.Absolute), job.Username, job.Password)); try { - switch (job.Operation) + if (job.Content != null) { - case "Create": - { - var doc = job.Content.ToString(); - - var response = await client.IndexAsync(job.IndexName, job.IndexType, job.ContentId, doc); - - return (response.Body, response.OriginalException); - } - - case "Update": - { - var doc = new JObject(new JProperty("doc", job.Content)).ToString(); + var doc = job.Content.ToString(); - var response = await client.UpdateAsync(job.IndexName, job.IndexType, job.ContentId, doc); + var response = await client.IndexAsync(job.IndexName, job.IndexType, job.ContentId, doc); - return (response.Body, response.OriginalException); - } - - case "Delete": - { - var response = await client.DeleteAsync(job.IndexName, job.IndexType, job.ContentId); - - return (response.Body, response.OriginalException); - } + return (response.Body, response.OriginalException); + } + else + { + var response = await client.DeleteAsync(job.IndexName, job.IndexType, job.ContentId); - default: - return (null, null); + return (response.Body, response.OriginalException); } } catch (ElasticsearchClientException ex) diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/FastlyActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/FastlyActionHandler.cs index 8d76518cc..07ed03cf4 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/FastlyActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/FastlyActionHandler.cs @@ -8,10 +8,9 @@ using System; using System.Net.Http; using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.HandleRules.Actions.Utils; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Domain.Apps.Events; -using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.Http; #pragma warning disable SA1649 // File name must match first type name @@ -28,51 +27,37 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions public sealed class FastlyActionHandler : RuleActionHandler { private const string Description = "Purge key in fastly"; - private const string DescriptionIgnore = "Ignore"; - protected override Task<(string Description, FastlyJob Data)> CreateJobAsync(Envelope @event, string eventName, FastlyAction action) + private readonly ClientPool clients; + + public FastlyActionHandler() { - if (@event.Headers.Contains(CommonHeaders.AggregateId)) + clients = new ClientPool(key => { - var ruleJob = new FastlyJob + return new HttpClient { - Key = @event.Headers.AggregateId().ToString(), - FastlyApiKey = action.ApiKey, - FastlyServiceID = action.ServiceId + Timeout = TimeSpan.FromSeconds(2) }; - - return Task.FromResult((Description, ruleJob)); - } - - return Task.FromResult((DescriptionIgnore, new FastlyJob())); + }); } - protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(FastlyJob job) + protected override (string Description, FastlyJob Data) CreateJob(EnrichedEvent @event, FastlyAction action) { - if (string.IsNullOrWhiteSpace(job.Key)) - { - return (null, new InvalidOperationException("The action cannot handle this event.")); - } - - var requestMsg = BuildRequest(job); - - HttpResponseMessage response = null; - - try + var ruleJob = new FastlyJob { - response = await HttpClientPool.GetHttpClient().SendAsync(requestMsg); + Key = @event.AggregateId.ToString(), + FastlyApiKey = action.ApiKey, + FastlyServiceID = action.ServiceId + }; - var responseString = await response.Content.ReadAsStringAsync(); - var requestDump = DumpFormatter.BuildDump(requestMsg, response, null, responseString, TimeSpan.Zero, false); + return (Description, ruleJob); + } - return (requestDump, null); - } - catch (Exception ex) - { - var requestDump = DumpFormatter.BuildDump(requestMsg, response, null, ex.ToString(), TimeSpan.Zero, false); + protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(FastlyJob job) + { + var httpClient = clients.GetClient(string.Empty); - return (requestDump, ex); - } + return await httpClient.OneWayRequestAsync(BuildRequest(job), null); } private static HttpRequestMessage BuildRequest(FastlyJob job) diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/MediumActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/MediumActionHandler.cs new file mode 100644 index 000000000..fae881a2a --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/MediumActionHandler.cs @@ -0,0 +1,145 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +#pragma warning disable SA1649 // File name must match first type name + +using System; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.HandleRules.Actions.Utils; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Rules.Actions; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Http; + +namespace Squidex.Domain.Apps.Core.HandleRules.Actions +{ + public sealed class MediumJob + { + public string RequestBody { get; set; } + + public string AccessToken { get; set; } + } + + public sealed class MediumActionHandler : RuleActionHandler + { + private const string Description = "Post to medium"; + + private readonly RuleEventFormatter formatter; + private readonly ClientPool clients; + + public MediumActionHandler(RuleEventFormatter formatter) + { + Guard.NotNull(formatter, nameof(formatter)); + + this.formatter = formatter; + + clients = new ClientPool(key => + { + var client = new HttpClient + { + Timeout = TimeSpan.FromSeconds(4) + }; + + client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.DefaultRequestHeaders.Add("Accept-Charset", "utf-8"); + client.DefaultRequestHeaders.Add("User-Agent", "Squidex Headless CMS"); + + return client; + }); + } + + protected override (string Description, MediumJob Data) CreateJob(EnrichedEvent @event, MediumAction action) + { + var requestBody = + new JObject( + new JProperty("title", formatter.Format(action.Title, @event)), + new JProperty("contentFormat", action.IsHtml ? "html" : "markdown"), + new JProperty("content", formatter.Format(action.Content, @event)), + new JProperty("canonicalUrl", formatter.Format(action.CanonicalUrl, @event)), + new JProperty("tags", ParseTags(@event, action))); + + var ruleJob = new MediumJob { AccessToken = action.AccessToken, RequestBody = requestBody.ToString(Formatting.Indented) }; + + return (Description, ruleJob); + } + + private JArray ParseTags(EnrichedEvent @event, MediumAction action) + { + if (string.IsNullOrWhiteSpace(action.Tags)) + { + return null; + } + + string[] tags; + try + { + var jsonTags = formatter.Format(action.Tags, @event); + + tags = JsonConvert.DeserializeObject(jsonTags); + } + catch + { + tags = action.Tags.Split(','); + } + + return new JArray(tags); + } + + protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(MediumJob job) + { + var httpClient = clients.GetClient(string.Empty); + + string id; + + HttpResponseMessage response = null; + + var meRequest = BuildMeRequest(job); + try + { + response = await httpClient.SendAsync(meRequest); + + var responseString = await response.Content.ReadAsStringAsync(); + var responseJson = JToken.Parse(responseString); + + id = responseJson["data"]["id"].ToString(); + } + catch (Exception ex) + { + var requestDump = DumpFormatter.BuildDump(meRequest, response, ex.ToString()); + + return (requestDump, ex); + } + + return await httpClient.OneWayRequestAsync(BuildPostRequest(job, id), job.RequestBody); + } + + private static HttpRequestMessage BuildPostRequest(MediumJob job, string id) + { + var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.medium.com/v1/users/{id}/posts") + { + Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") + }; + + request.Headers.Add("Authorization", $"Bearer {job.AccessToken}"); + + return request; + } + + private static HttpRequestMessage BuildMeRequest(MediumJob job) + { + var request = new HttpRequestMessage(HttpMethod.Get, "https://api.medium.com/v1/me"); + + request.Headers.Add("Authorization", $"Bearer {job.AccessToken}"); + + return request; + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs index c9ec58e14..e10d7f66f 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs @@ -11,11 +11,10 @@ using System.Text; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.HandleRules.Actions.Utils; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Domain.Apps.Events; using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.Http; #pragma warning disable SA1649 // File name must match first type name @@ -42,17 +41,28 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions private const string Description = "Send message to slack"; private readonly RuleEventFormatter formatter; + private readonly ClientPool clients; public SlackActionHandler(RuleEventFormatter formatter) { Guard.NotNull(formatter, nameof(formatter)); this.formatter = formatter; + + clients = new ClientPool(key => + { + return new HttpClient + { + Timeout = TimeSpan.FromSeconds(2) + }; + }); } - protected override async Task<(string Description, SlackJob Data)> CreateJobAsync(Envelope @event, string eventName, SlackAction action) + protected override (string Description, SlackJob Data) CreateJob(EnrichedEvent @event, SlackAction action) { - var body = await CreatePayloadAsync(@event, action.Text); + var body = + new JObject( + new JProperty("text", formatter.Format(action.Text, @event))); var ruleJob = new SlackJob { @@ -63,33 +73,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions return (Description, ruleJob); } - private async Task CreatePayloadAsync(Envelope @event, string text) + protected override Task<(string Dump, Exception Exception)> ExecuteJobAsync(SlackJob job) { - return new JObject(new JProperty("text", await formatter.FormatStringAsync(text, @event))); - } + var httpClient = clients.GetClient(string.Empty); - protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(SlackJob job) - { - var requestBody = job.Body; - var requestMessage = BuildRequest(job, requestBody); - - HttpResponseMessage response = null; - - try - { - response = await HttpClientPool.GetHttpClient().SendAsync(requestMessage); - - var responseString = await response.Content.ReadAsStringAsync(); - var requestDump = DumpFormatter.BuildDump(requestMessage, response, requestBody, responseString, TimeSpan.Zero, false); - - return (requestDump, null); - } - catch (Exception ex) - { - var requestDump = DumpFormatter.BuildDump(requestMessage, response, requestBody, ex.ToString(), TimeSpan.Zero, false); - - return (requestDump, ex); - } + return httpClient.OneWayRequestAsync(BuildRequest(job, job.Body), job.Body); } private static HttpRequestMessage BuildRequest(SlackJob job, string requestBody) diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/Utils/HttpHelper.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/Utils/HttpHelper.cs new file mode 100644 index 000000000..183f72066 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/Utils/HttpHelper.cs @@ -0,0 +1,45 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Squidex.Infrastructure.Http; + +namespace Squidex.Domain.Apps.Core.HandleRules.Actions.Utils +{ + public static class HttpHelper + { + public static async Task<(string Dump, Exception Exception)> OneWayRequestAsync(this HttpClient client, HttpRequestMessage request, string requestBody = null) + { + HttpResponseMessage response = null; + try + { + response = await client.SendAsync(request); + + var responseString = await response.Content.ReadAsStringAsync(); + + var requestDump = DumpFormatter.BuildDump(request, response, requestBody, responseString); + + Exception ex = null; + + if (!response.IsSuccessStatusCode) + { + ex = new HttpRequestException($"Response code does not indicate success: {(int)response.StatusCode} ({response.StatusCode})."); + } + + return (requestDump, ex); + } + catch (Exception ex) + { + var requestDump = DumpFormatter.BuildDump(request, response, requestBody, ex.ToString()); + + return (requestDump, ex); + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs index 316dc125b..ca7e9035b 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs @@ -11,11 +11,10 @@ using System.Text; using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.HandleRules.Actions.Utils; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Actions; -using Squidex.Domain.Apps.Events; using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.Http; #pragma warning disable SA1649 // File name must match first type name @@ -24,7 +23,9 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions public sealed class WebhookJob { public string RequestUrl { get; set; } + public string RequestSignature { get; set; } + public string RequestBodyV2 { get; set; } public JObject RequestBody { get; set; } @@ -41,58 +42,48 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions public sealed class WebhookActionHandler : RuleActionHandler { private readonly RuleEventFormatter formatter; + private readonly ClientPool clients; public WebhookActionHandler(RuleEventFormatter formatter) { Guard.NotNull(formatter, nameof(formatter)); this.formatter = formatter; + + clients = new ClientPool(key => + { + var client = new HttpClient + { + Timeout = TimeSpan.FromSeconds(4) + }; + + client.DefaultRequestHeaders.Add("User-Agent", "Squidex Webhook"); + + return client; + }); } - protected override async Task<(string Description, WebhookJob Data)> CreateJobAsync(Envelope @event, string eventName, WebhookAction action) + protected override (string Description, WebhookJob Data) CreateJob(EnrichedEvent @event, WebhookAction action) { - var body = formatter.ToRouteData(@event, eventName).ToString(Formatting.Indented); + var requestBody = formatter.ToEnvelope(@event).ToString(Formatting.Indented); + var requestUrl = formatter.Format(action.Url.ToString(), @event); - var ruleDescription = $"Send event to webhook '{action.Url}'"; + var ruleDescription = $"Send event to webhook '{requestUrl}'"; var ruleJob = new WebhookJob { - RequestUrl = await formatter.FormatStringAsync(action.Url.ToString(), @event), - RequestSignature = $"{body}{action.SharedSecret}".Sha256Base64(), - RequestBodyV2 = body + RequestUrl = requestUrl, + RequestSignature = $"{requestBody}{action.SharedSecret}".Sha256Base64(), + RequestBodyV2 = requestBody }; return (ruleDescription, ruleJob); } - protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(WebhookJob job) + protected override Task<(string Dump, Exception Exception)> ExecuteJobAsync(WebhookJob job) { - var requestBody = job.Body; - var requestMessage = BuildRequest(job, requestBody); - - HttpResponseMessage response = null; - - try - { - response = await HttpClientPool.GetHttpClient().SendAsync(requestMessage); - - var responseString = await response.Content.ReadAsStringAsync(); - var requestDump = DumpFormatter.BuildDump(requestMessage, response, requestBody, responseString, TimeSpan.Zero, false); - - Exception ex = null; - - if (!response.IsSuccessStatusCode) - { - ex = new HttpRequestException($"Response code does not indicate success: {(int)response.StatusCode} ({response.StatusCode})."); - } + var httpClient = clients.GetClient(string.Empty); - return (requestDump, ex); - } - catch (Exception ex) - { - var requestDump = DumpFormatter.BuildDump(requestMessage, response, requestBody, ex.ToString(), TimeSpan.Zero, false); - - return (requestDump, ex); - } + return httpClient.OneWayRequestAsync(BuildRequest(job, job.Body), job.Body); } private static HttpRequestMessage BuildRequest(WebhookJob job, string requestBody) @@ -103,7 +94,6 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions }; request.Headers.Add("X-Signature", job.RequestSignature); - request.Headers.Add("User-Agent", "Squidex Webhook"); return request; } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEvent.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEvent.cs new file mode 100644 index 000000000..56e0303b0 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEvent.cs @@ -0,0 +1,47 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using NodaTime; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents +{ + public sealed class EnrichedAssetEvent : EnrichedEvent + { + public EnrichedAssetEventType Type { get; set; } + + public Guid Id { get; set; } + + public Instant Created { get; set; } + + public Instant LastModified { get; set; } + + public RefToken CreatedBy { get; set; } + + public RefToken LastModifiedBy { get; set; } + + public string MimeType { get; set; } + + public string FileName { get; set; } + + public long FileVersion { get; set; } + + public long FileSize { get; set; } + + public bool IsImage { get; set; } + + public int? PixelWidth { get; set; } + + public int? PixelHeight { get; set; } + + public override Guid AggregateId + { + get { return Id; } + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEventType.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEventType.cs new file mode 100644 index 000000000..0e66499b2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEventType.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents +{ + public enum EnrichedAssetEventType + { + Created, + Deleted, + Renamed, + Updated + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEvent.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEvent.cs new file mode 100644 index 000000000..88af9945c --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEvent.cs @@ -0,0 +1,38 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using NodaTime; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents +{ + public sealed class EnrichedContentEvent : EnrichedSchemaEvent + { + public EnrichedContentEventType Type { get; set; } + + public Guid Id { get; set; } + + public Instant Created { get; set; } + + public Instant LastModified { get; set; } + + public RefToken CreatedBy { get; set; } + + public RefToken LastModifiedBy { get; set; } + + public NamedContentData Data { get; set; } + + public Status Status { get; set; } + + public override Guid AggregateId + { + get { return Id; } + } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs new file mode 100644 index 000000000..45148a8e2 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents +{ + public enum EnrichedContentEventType + { + Archived, + Created, + Deleted, + Published, + Restored, + Unpublished, + Updated + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedEvent.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedEvent.cs new file mode 100644 index 000000000..b95a939bf --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedEvent.cs @@ -0,0 +1,35 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Newtonsoft.Json; +using NodaTime; +using Squidex.Infrastructure; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents +{ + public abstract class EnrichedEvent + { + public NamedId AppId { get; set; } + + public RefToken Actor { get; set; } + + public Instant Timestamp { get; set; } + + public long Version { get; set; } + + [JsonIgnore] + public abstract Guid AggregateId { get; } + + [JsonIgnore] + public string Name { get; set; } + + [JsonIgnore] + public IUser User { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedSchemaEvent.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedSchemaEvent.cs new file mode 100644 index 000000000..528121c3b --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedSchemaEvent.cs @@ -0,0 +1,17 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents +{ + public abstract class EnrichedSchemaEvent : EnrichedEvent + { + public NamedId SchemaId { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/HttpClientPool.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IContentResolver.cs similarity index 53% rename from src/Squidex.Domain.Apps.Core.Operations/HandleRules/HttpClientPool.cs rename to src/Squidex.Domain.Apps.Core.Operations/HandleRules/IContentResolver.cs index 231920699..d25b17ea4 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/HttpClientPool.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IContentResolver.cs @@ -6,20 +6,13 @@ // ========================================================================== using System; -using System.Net.Http; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; namespace Squidex.Domain.Apps.Core.HandleRules { - public static class HttpClientPool + public interface IContentResolver { - private static readonly ClientPool Pool = new ClientPool(key => - { - return new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; - }); - - public static HttpClient GetHttpClient() - { - return Pool.GetClient(string.Empty); - } + Task GetContentDataAsync(Guid id); } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs new file mode 100644 index 000000000..6d2e7961d --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Core.HandleRules +{ + public interface IEventEnricher + { + Task EnrichAsync(Envelope @event); + } +} diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleActionHandler.cs index ec0fdf198..deced9228 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleActionHandler.cs @@ -8,9 +8,8 @@ using System; using System.Threading.Tasks; using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Events; -using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Core.HandleRules { @@ -18,7 +17,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules { Type ActionType { get; } - Task<(string Description, JObject Data)> CreateJobAsync(Envelope @event, string eventName, RuleAction action); + Task<(string Description, JObject Data)> CreateJobAsync(EnrichedEvent @event, RuleAction action); Task<(string Dump, Exception Exception)> ExecuteJobAsync(JObject data); } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionHandler.cs index ea1ead7f5..ddab38183 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionHandler.cs @@ -8,9 +8,10 @@ using System; using System.Threading.Tasks; using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules; -using Squidex.Domain.Apps.Events; -using Squidex.Infrastructure.EventSourcing; + +#pragma warning disable RECS0083 // Shows NotImplementedException throws in the quick task bar namespace Squidex.Domain.Apps.Core.HandleRules { @@ -21,9 +22,9 @@ namespace Squidex.Domain.Apps.Core.HandleRules get { return typeof(TAction); } } - async Task<(string Description, JObject Data)> IRuleActionHandler.CreateJobAsync(Envelope @event, string eventName, RuleAction action) + async Task<(string Description, JObject Data)> IRuleActionHandler.CreateJobAsync(EnrichedEvent @event, RuleAction action) { - var (description, data) = await CreateJobAsync(@event, eventName, (TAction)action); + var (description, data) = await CreateJobAsync(@event, (TAction)action); return (description, JObject.FromObject(data)); } @@ -35,7 +36,15 @@ namespace Squidex.Domain.Apps.Core.HandleRules return await ExecuteJobAsync(typedData); } - protected abstract Task<(string Description, TData Data)> CreateJobAsync(Envelope @event, string eventName, TAction action); + protected virtual Task<(string Description, TData Data)> CreateJobAsync(EnrichedEvent @event, TAction action) + { + return Task.FromResult(CreateJob(@event, action)); + } + + protected virtual (string Description, TData Data) CreateJob(EnrichedEvent @event, TAction action) + { + throw new NotImplementedException(); + } protected abstract Task<(string Dump, Exception Exception)> ExecuteJobAsync(TData job); } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs index 6401a786b..c724565b8 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs @@ -6,18 +6,15 @@ // =========================================-================================= using System; +using System.Collections.Generic; using System.Globalization; using System.Text; using System.Text.RegularExpressions; -using System.Threading.Tasks; -using Microsoft.Extensions.Caching.Memory; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Contents; -using Squidex.Domain.Apps.Events; -using Squidex.Domain.Apps.Events.Contents; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; using Squidex.Infrastructure; -using Squidex.Infrastructure.EventSourcing; using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Core.HandleRules @@ -25,222 +22,271 @@ namespace Squidex.Domain.Apps.Core.HandleRules public class RuleEventFormatter { private const string Undefined = "UNDEFINED"; - private const string AppIdPlaceholder = "$APP_ID"; - private const string AppNamePlaceholder = "$APP_NAME"; - private const string SchemaIdPlaceholder = "$SCHEMA_ID"; - private const string SchemaNamePlaceholder = "$SCHEMA_NAME"; - private const string TimestampDatePlaceholder = "$TIMESTAMP_DATE"; - private const string TimestampDateTimePlaceholder = "$TIMESTAMP_DATETIME"; - private const string ContentActionPlaceholder = "$CONTENT_ACTION"; - private const string ContentUrlPlaceholder = "$CONTENT_URL"; - private const string UserNamePlaceholder = "$USER_NAME"; - private const string UserEmailPlaceholder = "$USER_EMAIL"; - private static readonly Regex ContentDataPlaceholder = new Regex(@"\$CONTENT_DATA(\.([0-9A-Za-z\-_]*)){2,}", RegexOptions.Compiled); - private static readonly TimeSpan UserCacheDuration = TimeSpan.FromMinutes(10); + private static readonly Regex ContentDataPlaceholder = new Regex(@"^CONTENT_DATA(\.([0-9A-Za-z\-_]*)){2,}", RegexOptions.Compiled); + private static readonly Regex ContentDataPlaceholder2 = new Regex(@"^\{CONTENT_DATA(\.([0-9A-Za-z\-_]*)){2,}\}", RegexOptions.Compiled); + private readonly List<(string Pattern, Func Replacer)> patterns = new List<(string Pattern, Func Replacer)>(); private readonly JsonSerializer serializer; private readonly IRuleUrlGenerator urlGenerator; - private readonly IMemoryCache memoryCache; - private readonly IUserResolver userResolver; - public RuleEventFormatter(JsonSerializer serializer, IRuleUrlGenerator urlGenerator, IMemoryCache memoryCache, IUserResolver userResolver) + public RuleEventFormatter(JsonSerializer serializer, IRuleUrlGenerator urlGenerator) { - Guard.NotNull(memoryCache, nameof(memoryCache)); Guard.NotNull(serializer, nameof(serializer)); Guard.NotNull(urlGenerator, nameof(urlGenerator)); - Guard.NotNull(userResolver, nameof(userResolver)); - this.memoryCache = memoryCache; this.serializer = serializer; - this.userResolver = userResolver; this.urlGenerator = urlGenerator; + + AddPattern("APP_ID", AppId); + AddPattern("APP_NAME", AppName); + AddPattern("CONTENT_ACTION", ContentAction); + AddPattern("CONTENT_URL", ContentUrl); + AddPattern("SCHEMA_ID", SchemaId); + AddPattern("SCHEMA_NAME", SchemaName); + AddPattern("TIMESTAMP_DATETIME", TimestampTime); + AddPattern("TIMESTAMP_DATE", TimestampDate); + AddPattern("USER_NAME", UserName); + AddPattern("USER_EMAIL", UserEmail); } - public virtual JToken ToRouteData(object value) + private void AddPattern(string placeholder, Func generator) { - return JToken.FromObject(value, serializer); + patterns.Add((placeholder, generator)); } - public virtual JToken ToRouteData(Envelope @event, string eventName) + public virtual JObject ToPayload(T @event) { - return new JObject( - new JProperty("type", eventName), - new JProperty("payload", JToken.FromObject(@event.Payload, serializer)), - new JProperty("timestamp", @event.Headers.Timestamp().ToString())); + return JObject.FromObject(@event, serializer); } - public async virtual Task FormatStringAsync(string text, Envelope @event) + public virtual JObject ToEnvelope(EnrichedEvent @event) { - var sb = new StringBuilder(text); + return new JObject( + new JProperty("type", @event.Name), + new JProperty("payload", ToPayload(@event)), + new JProperty("timestamp", @event.Timestamp.ToString())); + } - if (@event.Headers.Contains(CommonHeaders.Timestamp)) + public string Format(string text, EnrichedEvent @event) + { + if (string.IsNullOrWhiteSpace(text)) { - var timestamp = @event.Headers.Timestamp().ToDateTimeUtc(); - - sb.Replace(TimestampDateTimePlaceholder, timestamp.ToString("yyy-MM-dd-hh-mm-ss", CultureInfo.InvariantCulture)); - sb.Replace(TimestampDatePlaceholder, timestamp.ToString("yyy-MM-dd", CultureInfo.InvariantCulture)); + return text; } - if (@event.Payload.AppId != null) - { - sb.Replace(AppIdPlaceholder, @event.Payload.AppId.Id.ToString()); - sb.Replace(AppNamePlaceholder, @event.Payload.AppId.Name); - } + var current = text.AsSpan(); - if (@event.Payload is SchemaEvent schemaEvent && schemaEvent.SchemaId != null) - { - sb.Replace(SchemaIdPlaceholder, schemaEvent.SchemaId.Id.ToString()); - sb.Replace(SchemaNamePlaceholder, schemaEvent.SchemaId.Name); - } + var sb = new StringBuilder(); - if (@event.Payload is ContentEvent contentEvent) + for (var i = 0; i < current.Length; i++) { - sb.Replace(ContentUrlPlaceholder, urlGenerator.GenerateContentUIUrl(@event.Payload.AppId, contentEvent.SchemaId, contentEvent.ContentId)); - } + var c = current[i]; + + if (c == '$') + { + sb.Append(current.Slice(0, i)); - await FormatUserInfoAsync(@event, sb); + current = current.Slice(i); - FormatContentAction(@event, sb); + var test = current.Slice(1); + var tested = false; - var result = sb.ToString(); + for (var j = 0; j < patterns.Count; j++) + { + var (Pattern, Replacer) = patterns[j]; - if (@event.Payload is ContentCreated contentCreated && contentCreated.Data != null) - { - result = ReplaceData(contentCreated.Data, result); - } + if (test.StartsWith(Pattern, StringComparison.OrdinalIgnoreCase)) + { + sb.Append(Replacer(@event)); - if (@event.Payload is ContentUpdated contentUpdated && contentUpdated.Data != null) - { - result = ReplaceData(contentUpdated.Data, result); + current = current.Slice(Pattern.Length + 1); + i = 0; + + tested = true; + break; + } + } + + if (!tested && + (test.StartsWith("CONTENT_DATA", StringComparison.OrdinalIgnoreCase) || + test.StartsWith("{CONTENT_DATA", StringComparison.OrdinalIgnoreCase))) + { + var currentString = new string(test); + + var match = ContentDataPlaceholder.Match(currentString); + + if (!match.Success) + { + match = ContentDataPlaceholder2.Match(currentString); + } + + if (match.Success) + { + if (@event is EnrichedContentEvent contentEvent) + { + sb.Append(CalculateData(contentEvent.Data, match)); + } + else + { + sb.Append(Undefined); + } + + current = current.Slice(match.Length + 1); + i = 0; + } + } + } } - return result; + sb.Append(current); + + return sb.ToString(); } - private async Task FormatUserInfoAsync(Envelope @event, StringBuilder sb) + private static string TimestampDate(EnrichedEvent @event) { - var text = sb.ToString(); + return @event.Timestamp.ToDateTimeUtc().ToString("yyy-MM-dd", CultureInfo.InvariantCulture); + } - if (text.Contains(UserEmailPlaceholder) || text.Contains(UserNamePlaceholder)) - { - var actor = @event.Payload.Actor; + private static string TimestampTime(EnrichedEvent @event) + { + return @event.Timestamp.ToDateTimeUtc().ToString("yyy-MM-dd-hh-mm-ss", CultureInfo.InvariantCulture); + } - if (actor.Type.Equals("client", StringComparison.OrdinalIgnoreCase)) - { - var displayText = actor.ToString(); + private static string AppId(EnrichedEvent @event) + { + return @event.AppId.Id.ToString(); + } - sb.Replace(UserEmailPlaceholder, displayText); - sb.Replace(UserNamePlaceholder, displayText); - } - else - { - var user = await FindUserAsync(actor); + private static string AppName(EnrichedEvent @event) + { + return @event.AppId.Name; + } - if (user != null) - { - sb.Replace(UserEmailPlaceholder, user.Email); - sb.Replace(UserNamePlaceholder, user.DisplayName()); - } - else - { - sb.Replace(UserEmailPlaceholder, Undefined); - sb.Replace(UserNamePlaceholder, Undefined); - } - } + private static string SchemaId(EnrichedEvent @event) + { + if (@event is EnrichedSchemaEvent schemaEvent) + { + return schemaEvent.SchemaId.Id.ToString(); } + + return Undefined; } - private static void FormatContentAction(Envelope @event, StringBuilder sb) + private static string SchemaName(EnrichedEvent @event) { - switch (@event.Payload) + if (@event is EnrichedSchemaEvent schemaEvent) { - case ContentCreated contentCreated: - sb.Replace(ContentActionPlaceholder, "created"); - break; - - case ContentUpdated contentUpdated: - sb.Replace(ContentActionPlaceholder, "updated"); - break; + return schemaEvent.SchemaId.Name; + } - case ContentStatusChanged contentStatusChanged: - sb.Replace(ContentActionPlaceholder, $"set to {contentStatusChanged.Status.ToString().ToLowerInvariant()}"); - break; + return Undefined; + } - case ContentDeleted contentDeleted: - sb.Replace(ContentActionPlaceholder, "deleted"); - break; + private static string ContentAction(EnrichedEvent @event) + { + if (@event is EnrichedContentEvent contentEvent) + { + return contentEvent.Type.ToString().ToLowerInvariant(); } + + return Undefined; } - private static string ReplaceData(NamedContentData data, string text) + private string ContentUrl(EnrichedEvent @event) { - return ContentDataPlaceholder.Replace(text, match => + if (@event is EnrichedContentEvent contentEvent) { - var captures = match.Groups[2].Captures; - - var path = new string[captures.Count]; + return urlGenerator.GenerateContentUIUrl(contentEvent.AppId, contentEvent.SchemaId, contentEvent.Id); + } - for (var i = 0; i < path.Length; i++) - { - path[i] = captures[i].Value; - } + return Undefined; + } - if (!data.TryGetValue(path[0], out var field)) + private static string UserName(EnrichedEvent @event) + { + if (@event.Actor != null) + { + if (@event.Actor.Type.Equals("client", StringComparison.OrdinalIgnoreCase)) { - return Undefined; + return @event.Actor.ToString(); } - if (!field.TryGetValue(path[1], out var value)) + if (@event.User != null) { - return Undefined; + return @event.User.DisplayName(); } + } - for (var j = 2; j < path.Length; j++) - { - if (value is JObject obj && obj.TryGetValue(path[j], out value)) - { - continue; - } - if (value is JArray arr && int.TryParse(path[j], out var idx) && idx >= 0 && idx < arr.Count) - { - value = arr[idx]; - } - else - { - return Undefined; - } - } + return Undefined; + } - if (value == null || value.Type == JTokenType.Null || value.Type == JTokenType.Undefined) + private static string UserEmail(EnrichedEvent @event) + { + if (@event.Actor != null) + { + if (@event.Actor.Type.Equals("client", StringComparison.OrdinalIgnoreCase)) { - return Undefined; + return @event.Actor.ToString(); } - if (value is JValue jValue && jValue != null) + if (@event.User != null) { - return jValue.Value.ToString(); + return @event.User.Email; } + } - return value?.ToString(Formatting.Indented) ?? Undefined; - }); + return Undefined; } - private Task FindUserAsync(RefToken actor) + private static string CalculateData(NamedContentData data, Match match) { - var key = $"RuleEventFormatter_Users_${actor.Identifier}"; + var captures = match.Groups[2].Captures; + + var path = new string[captures.Count]; + + for (var i = 0; i < path.Length; i++) + { + path[i] = captures[i].Value; + } + + if (!data.TryGetValue(path[0], out var field)) + { + return Undefined; + } - return memoryCache.GetOrCreateAsync(key, async x => + if (!field.TryGetValue(path[1], out var value)) { - x.AbsoluteExpirationRelativeToNow = UserCacheDuration; + return Undefined; + } - try + for (var j = 2; j < path.Length; j++) + { + if (value is JObject obj && obj.TryGetValue(path[j], out value)) { - return await userResolver.FindByIdOrEmailAsync(actor.Identifier); + continue; } - catch + + if (value is JArray arr && int.TryParse(path[j], out var idx) && idx >= 0 && idx < arr.Count) { - return null; + value = arr[idx]; } - }); + else + { + return Undefined; + } + } + + if (value == null || value.Type == JTokenType.Null || value.Type == JTokenType.Undefined) + { + return Undefined; + } + + if (value is JValue jValue && jValue != null) + { + return jValue.Value.ToString(); + } + + return value?.ToString(Formatting.Indented) ?? Undefined; } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs index 019399f0b..58e862cc6 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs @@ -26,17 +26,20 @@ namespace Squidex.Domain.Apps.Core.HandleRules private readonly Dictionary ruleActionHandlers; private readonly Dictionary ruleTriggerHandlers; private readonly TypeNameRegistry typeNameRegistry; + private readonly IEventEnricher eventEnricher; private readonly IClock clock; public RuleService( IEnumerable ruleTriggerHandlers, IEnumerable ruleActionHandlers, + IEventEnricher eventEnricher, IClock clock, TypeNameRegistry typeNameRegistry) { Guard.NotNull(ruleTriggerHandlers, nameof(ruleTriggerHandlers)); Guard.NotNull(ruleActionHandlers, nameof(ruleActionHandlers)); Guard.NotNull(typeNameRegistry, nameof(typeNameRegistry)); + Guard.NotNull(eventEnricher, nameof(eventEnricher)); Guard.NotNull(clock, nameof(clock)); this.typeNameRegistry = typeNameRegistry; @@ -44,6 +47,8 @@ namespace Squidex.Domain.Apps.Core.HandleRules this.ruleTriggerHandlers = ruleTriggerHandlers.ToDictionary(x => x.TriggerType); this.ruleActionHandlers = ruleActionHandlers.ToDictionary(x => x.ActionType); + this.eventEnricher = eventEnricher; + this.clock = clock; } @@ -76,41 +81,38 @@ namespace Squidex.Domain.Apps.Core.HandleRules return null; } - var eventName = CreateEventName(appEvent); - var now = clock.GetCurrentInstant(); - var actionName = typeNameRegistry.GetName(actionType); - var actionData = await actionHandler.CreateJobAsync(appEventEnvelope, eventName, rule.Action); - var eventTime = @event.Headers.Contains(CommonHeaders.Timestamp) ? @event.Headers.Timestamp() : now; - var aggregateId = - @event.Headers.Contains(CommonHeaders.AggregateId) ? - @event.Headers.AggregateId() : - Guid.NewGuid(); + var expires = eventTime.Plus(Constants.ExpirationTime); + + if (expires < now) + { + return null; + } + + var enrichedEvent = await eventEnricher.EnrichAsync(appEventEnvelope); + + var actionName = typeNameRegistry.GetName(actionType); + var actionData = await actionHandler.CreateJobAsync(enrichedEvent, rule.Action); var job = new RuleJob { JobId = Guid.NewGuid(), ActionName = actionName, ActionData = actionData.Data, - AggregateId = aggregateId, + AggregateId = enrichedEvent.AggregateId, AppId = appEvent.AppId.Id, Created = now, - EventName = eventName, - Expires = eventTime.Plus(Constants.ExpirationTime), + EventName = enrichedEvent.Name, + Expires = expires, Description = actionData.Description }; - if (job.Expires < now) - { - return null; - } - return job; } @@ -152,22 +154,5 @@ namespace Squidex.Domain.Apps.Core.HandleRules return (ex.ToString(), RuleResult.Failed, TimeSpan.Zero); } } - - private string CreateEventName(AppEvent appEvent) - { - var eventName = typeNameRegistry.GetName(appEvent.GetType()); - - if (appEvent is SchemaEvent schemaEvent) - { - if (eventName.StartsWith(ContentPrefix, StringComparison.Ordinal)) - { - eventName = eventName.Substring(ContentPrefix.Length); - } - - return $"{schemaEvent.SchemaId.Name.ToPascalCase()}{eventName}"; - } - - return eventName; - } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs index 6914f3d44..a65ccbcc7 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs @@ -17,7 +17,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Triggers { protected override bool Triggers(Envelope @event, ContentChangedTrigger trigger) { - if (trigger.HandleAll && @event.Payload is ContentEvent) + if (trigger.HandleAll && + @event.Payload is ContentEvent && + !(@event.Payload is ContentChangesPublished) && + !(@event.Payload is ContentChangesDiscarded) && + !(@event.Payload is ContentUpdateProposed)) { return true; } @@ -44,10 +48,48 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Triggers private static bool MatchsType(ContentChangedTriggerSchema schema, SchemaEvent @event) { return - (schema.SendCreate && @event is ContentCreated) || - (schema.SendUpdate && @event is ContentUpdated) || - (schema.SendDelete && @event is ContentDeleted) || - (schema.SendPublish && @event is ContentStatusChanged statusChanged && statusChanged.Status == Status.Published); + IsArchived(schema, @event) || + IsCreate(schema, @event) || + IsDelete(schema, @event) || + IsPublished(schema, @event) || + IsRestored(schema, @event) || + IsUpdate(schema, @event) || + IsUnpublished(schema, @event); + } + + private static bool IsPublished(ContentChangedTriggerSchema schema, SchemaEvent @event) + { + return schema.SendPublish && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Published; + } + + private static bool IsRestored(ContentChangedTriggerSchema schema, SchemaEvent @event) + { + return schema.SendRestore && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Restored; + } + + private static bool IsArchived(ContentChangedTriggerSchema schema, SchemaEvent @event) + { + return schema.SendArchived && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Archived; + } + + private static bool IsUnpublished(ContentChangedTriggerSchema schema, SchemaEvent @event) + { + return schema.SendUnpublish && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Unpublished; + } + + private static bool IsCreate(ContentChangedTriggerSchema schema, SchemaEvent @event) + { + return schema.SendCreate && @event is ContentCreated; + } + + private static bool IsUpdate(ContentChangedTriggerSchema schema, SchemaEvent @event) + { + return schema.SendUpdate && @event is ContentUpdated || schema.SendUpdate && @event is ContentChangesPublished; + } + + private static bool IsDelete(ContentChangedTriggerSchema schema, SchemaEvent @event) + { + return (schema.SendDelete && @event is ContentDeleted); } } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj index a109f585f..2bd9be1b5 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj +++ b/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj @@ -1,6 +1,6 @@  - netstandard2.0 + netcoreapp2.1 Squidex.Domain.Apps.Core diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj b/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj index 0e0ab5bc1..7cb1a8a49 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj @@ -1,6 +1,6 @@  - netstandard2.0 + netcoreapp2.1 full diff --git a/src/Squidex.Domain.Apps.Entities/AppProvider.cs b/src/Squidex.Domain.Apps.Entities/AppProvider.cs index a00bda2d7..c8c821f49 100644 --- a/src/Squidex.Domain.Apps.Entities/AppProvider.cs +++ b/src/Squidex.Domain.Apps.Entities/AppProvider.cs @@ -31,6 +31,7 @@ namespace Squidex.Domain.Apps.Entities Guard.NotNull(localCache, nameof(localCache)); this.grainFactory = grainFactory; + this.localCache = localCache; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs index 975b1cd36..4ebf19747 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs @@ -26,7 +26,7 @@ using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Entities.Apps { - public class AppGrain : SquidexDomainObjectGrain, IAppGrain + public sealed class AppGrain : SquidexDomainObjectGrain, IAppGrain { private readonly InitialPatterns initialPatterns; private readonly IAppProvider appProvider; @@ -72,14 +72,14 @@ namespace Squidex.Domain.Apps.Entities.Apps }); case AssignContributor assigneContributor: - return UpdateReturnAsync(assigneContributor, async c => + return UpdateReturnAsync(assigneContributor, (Func>)(async c => { await GuardAppContributors.CanAssign(Snapshot.Contributors, c, userResolver, appPlansProvider.GetPlan(Snapshot.Plan?.PlanId)); AssignContributor(c); - return EntityCreatedResult.Create(c.ContributorId, NewVersion); - }); + return EntityCreatedResult.Create(c.ContributorId, (long)base.Version); + })); case RemoveContributor removeContributor: return UpdateAsync(removeContributor, c => @@ -334,9 +334,9 @@ namespace Squidex.Domain.Apps.Entities.Apps return new AppContributorAssigned { ContributorId = actor.Identifier, Permission = AppContributorPermission.Owner }; } - public override void ApplyEvent(Envelope @event) + protected override AppState OnEvent(Envelope @event) { - ApplySnapshot(Snapshot.Apply(@event)); + return Snapshot.Apply(@event); } public Task> GetStateAsync() diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs index d9d1520b4..15d059cf1 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs @@ -22,7 +22,7 @@ using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Assets { - public class AssetGrain : SquidexDomainObjectGrain, IAssetGrain + public sealed class AssetGrain : SquidexDomainObjectGrainLogSnapshots, IAssetGrain { public AssetGrain(IStore store, ISemanticLog log) : base(store, log) @@ -34,23 +34,23 @@ namespace Squidex.Domain.Apps.Entities.Assets switch (command) { case CreateAsset createRule: - return CreateReturnAsync(createRule, c => + return CreateReturnAsync(createRule, (Func)(c => { GuardAsset.CanCreate(c); Create(c); - return new AssetSavedResult(NewVersion, Snapshot.FileVersion); - }); + return new AssetSavedResult((long)base.Version, Snapshot.FileVersion); + })); case UpdateAsset updateRule: - return UpdateReturnAsync(updateRule, c => + return UpdateReturnAsync(updateRule, (Func)(c => { GuardAsset.CanUpdate(c); Update(c); - return new AssetSavedResult(NewVersion, Snapshot.FileVersion); - }); + return new AssetSavedResult((long)base.Version, Snapshot.FileVersion); + })); case RenameAsset renameAsset: return UpdateAsync(renameAsset, c => { @@ -135,14 +135,14 @@ namespace Squidex.Domain.Apps.Entities.Assets } } - public override void ApplyEvent(Envelope @event) + protected override AssetState OnEvent(Envelope @event) { - ApplySnapshot(Snapshot.Apply(@event)); + return Snapshot.Apply(@event); } - public Task> GetStateAsync() + public Task> GetStateAsync(long version = EtagVersion.Any) { - return J.AsTask(Snapshot); + return J.AsTask(GetSnapshot(version)); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetGrain.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetGrain.cs index 864c0256a..4018d7e3d 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/IAssetGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/IAssetGrain.cs @@ -5,11 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Threading.Tasks; +using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Orleans; namespace Squidex.Domain.Apps.Entities.Assets { public interface IAssetGrain : IDomainObjectGrain { + Task> GetStateAsync(long version = EtagVersion.Any); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs index 755e30a1d..16f9841ea 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs @@ -20,12 +20,13 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Contents { - public class ContentGrain : SquidexDomainObjectGrain, IContentGrain + public sealed class ContentGrain : SquidexDomainObjectGrainLogSnapshots, IContentGrain { private readonly IAppProvider appProvider; private readonly IAssetRepository assetRepository; @@ -59,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Contents switch (command) { case CreateContent createContent: - return CreateReturnAsync(createContent, async c => + return CreateReturnAsync(createContent, (Func>)(async c => { var ctx = await CreateContext(c.AppId.Id, c.SchemaId.Id, () => "Failed to create content."); @@ -76,8 +77,8 @@ namespace Squidex.Domain.Apps.Entities.Contents Create(c); - return EntityCreatedResult.Create(c.Data, NewVersion); - }); + return EntityCreatedResult.Create(c.Data, (long)base.Version); + })); case UpdateContent updateContent: return UpdateReturnAsync(updateContent, c => @@ -116,9 +117,28 @@ namespace Squidex.Domain.Apps.Entities.Contents } else { - await ctx.ExecuteScriptAsync(x => x.ScriptChange, c.Status, c, Snapshot.Data); - - ChangeStatus(c); + var reason = StatusChange.Published; + + if (c.Status == Status.Published) + { + reason = StatusChange.Published; + } + else if (c.Status == Status.Archived) + { + reason = StatusChange.Archived; + } + else if (Snapshot.Status == Status.Published) + { + reason = StatusChange.Unpublished; + } + else + { + reason = StatusChange.Restored; + } + + await ctx.ExecuteScriptAsync(x => x.ScriptChange, reason, c, Snapshot.Data); + + ChangeStatus(c, reason); } } } @@ -196,7 +216,7 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - return new ContentDataChangedResult(newData, NewVersion); + return new ContentDataChangedResult(newData, Version); } public void Create(CreateContent command) @@ -205,7 +225,7 @@ namespace Squidex.Domain.Apps.Entities.Contents if (command.Publish) { - RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published })); + RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published, Change = StatusChange.Published })); } } @@ -244,9 +264,9 @@ namespace Squidex.Domain.Apps.Entities.Contents RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime.Value })); } - public void ChangeStatus(ChangeContentStatus command) + public void ChangeStatus(ChangeContentStatus command, StatusChange reason) { - RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged())); + RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Change = reason })); } private void RaiseEvent(SchemaEvent @event) @@ -272,22 +292,24 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - public override void ApplyEvent(Envelope @event) + protected override ContentState OnEvent(Envelope @event) { - ApplySnapshot(Snapshot.Apply(@event)); + return Snapshot.Apply(@event); } private async Task CreateContext(Guid appId, Guid schemaId, Func message) { var operationContext = - await ContentOperationContext.CreateAsync(appId, schemaId, - appProvider, - assetRepository, - contentRepository, - scriptEngine, - message); + await ContentOperationContext.CreateAsync( + appId, schemaId, + appProvider, assetRepository, contentRepository, scriptEngine, message); return operationContext; } + + public Task> GetStateAsync(long version = EtagVersion.Any) + { + return J.AsTask(GetSnapshot(version)); + } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/ContentVersionLoader.cs b/src/Squidex.Domain.Apps.Entities/Contents/ContentVersionLoader.cs index dca35ba34..7016766ef 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/ContentVersionLoader.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/ContentVersionLoader.cs @@ -7,52 +7,37 @@ using System; using System.Threading.Tasks; -using Squidex.Domain.Apps.Core.Schemas; -using Squidex.Domain.Apps.Entities.Contents.State; +using Orleans; using Squidex.Infrastructure; using Squidex.Infrastructure.Log; -using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Contents { public sealed class ContentVersionLoader : IContentVersionLoader { - private readonly IStore store; - private readonly FieldRegistry registry; + private readonly IGrainFactory grainFactory; - public ContentVersionLoader(IStore store, FieldRegistry registry) + public ContentVersionLoader(IGrainFactory grainFactory) { - Guard.NotNull(store, nameof(store)); - Guard.NotNull(registry, nameof(registry)); + Guard.NotNull(grainFactory, nameof(grainFactory)); - this.store = store; - - this.registry = registry; + this.grainFactory = grainFactory; } public async Task LoadAsync(Guid id, long version) { using (Profiler.TraceMethod()) { - var content = new ContentState(); - - var persistence = store.WithEventSourcing(id, e => - { - if (content.Version < version) - { - content = content.Apply(e); - content.Version++; - } - }); + var grain = grainFactory.GetGrain(id); - await persistence.ReadAsync(); + var content = await grain.GetStateAsync(version); - if (content.Version != version) + if (content.Value == null || content.Value.Version != version) { throw new DomainObjectNotFoundException(id.ToString(), typeof(IContentEntity)); } - return content; + return content.Value; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/IContentGrain.cs b/src/Squidex.Domain.Apps.Entities/Contents/IContentGrain.cs index 0b2d547c1..429a27746 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/IContentGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/IContentGrain.cs @@ -5,11 +5,15 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Threading.Tasks; +using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Orleans; namespace Squidex.Domain.Apps.Entities.Contents { public interface IContentGrain : IDomainObjectGrain { + Task> GetStateAsync(long version = EtagVersion.Any); } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs index f9028fcbf..aa3746811 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs @@ -32,10 +32,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.State public NamedContentData DataDraft { get; set; } [JsonProperty] - public Status Status { get; set; } + public ScheduleJob ScheduleJob { get; set; } [JsonProperty] - public ScheduleJob ScheduleJob { get; set; } + public Status Status { get; set; } [JsonProperty] public bool IsPending { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Rules/EventEnricher.cs b/src/Squidex.Domain.Apps.Entities/Rules/EventEnricher.cs new file mode 100644 index 000000000..c2a126881 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Rules/EventEnricher.cs @@ -0,0 +1,184 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Orleans; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.HandleRules; +using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Reflection; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Entities.Rules +{ + public sealed class EventEnricher : IEventEnricher + { + private static readonly TimeSpan UserCacheDuration = TimeSpan.FromMinutes(10); + private readonly IGrainFactory grainFactory; + private readonly IMemoryCache userCache; + private readonly IUserResolver userResolver; + + public EventEnricher(IGrainFactory grainFactory, IMemoryCache userCache, IUserResolver userResolver) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + Guard.NotNull(userCache, nameof(userCache)); + Guard.NotNull(userResolver, nameof(userResolver)); + + this.grainFactory = grainFactory; + this.userCache = userCache; + this.userResolver = userResolver; + } + + public async Task EnrichAsync(Envelope @event) + { + Guard.NotNull(@event, nameof(@event)); + + if (@event.Payload is ContentEvent contentEvent) + { + var result = new EnrichedContentEvent(); + + await Task.WhenAll( + EnrichContentAsync(result, contentEvent, @event), + EnrichDefaultAsync(result, @event)); + + return result; + } + + if (@event.Payload is AssetEvent assetEvent) + { + var result = new EnrichedAssetEvent(); + + await Task.WhenAll( + EnrichAssetAsync(result, assetEvent, @event), + EnrichDefaultAsync(result, @event)); + + return result; + } + + return null; + } + + private async Task EnrichAssetAsync(EnrichedAssetEvent result, AssetEvent assetEvent, Envelope @event) + { + var asset = + (await grainFactory + .GetGrain(assetEvent.AssetId) + .GetStateAsync(@event.Headers.EventStreamNumber())).Value; + + SimpleMapper.Map(asset, result); + + switch (assetEvent) + { + case AssetCreated _: + result.Type = EnrichedAssetEventType.Created; + break; + case AssetRenamed _: + result.Type = EnrichedAssetEventType.Renamed; + break; + case AssetUpdated _: + result.Type = EnrichedAssetEventType.Updated; + break; + case AssetDeleted _: + result.Type = EnrichedAssetEventType.Deleted; + break; + } + + result.Name = $"Asset{result.Type}"; + } + + private async Task EnrichContentAsync(EnrichedContentEvent result, ContentEvent contentEvent, Envelope @event) + { + var content = + (await grainFactory + .GetGrain(contentEvent.ContentId) + .GetStateAsync(@event.Headers.EventStreamNumber())).Value; + + SimpleMapper.Map(content, result); + + result.Data = content.Data ?? content.DataDraft; + + switch (contentEvent) + { + case ContentCreated _: + result.Type = EnrichedContentEventType.Created; + break; + case ContentDeleted _: + result.Type = EnrichedContentEventType.Deleted; + break; + case ContentChangesPublished _: + case ContentUpdated _: + result.Type = EnrichedContentEventType.Updated; + break; + case ContentStatusChanged contentStatusChanged: + switch (contentStatusChanged.Change) + { + case StatusChange.Published: + result.Type = EnrichedContentEventType.Published; + break; + case StatusChange.Unpublished: + result.Type = EnrichedContentEventType.Unpublished; + break; + case StatusChange.Archived: + result.Type = EnrichedContentEventType.Archived; + break; + case StatusChange.Restored: + result.Type = EnrichedContentEventType.Restored; + break; + } + + break; + } + + result.Name = $"{content.SchemaId.Name.ToPascalCase()}{result.Type}"; + } + + private async Task EnrichDefaultAsync(EnrichedEvent result, Envelope @event) + { + result.Timestamp = @event.Headers.Timestamp(); + + if (@event.Payload is SquidexEvent squidexEvent) + { + result.Actor = squidexEvent.Actor; + } + + if (@event.Payload is AppEvent appEvent) + { + result.AppId = appEvent.AppId; + } + + result.User = await FindUserAsync(result.Actor); + } + + private Task FindUserAsync(RefToken actor) + { + var key = $"EventEnrichers_Users_${actor.Identifier}"; + + return userCache.GetOrCreateAsync(key, async x => + { + x.AbsoluteExpirationRelativeToNow = UserCacheDuration; + + try + { + return await userResolver.FindByIdOrEmailAsync(actor.Identifier); + } + catch + { + return null; + } + }); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs b/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs index 6e2178bb3..3aadcc635 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs @@ -107,6 +107,28 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards return Task.FromResult>(errors); } + public Task> Visit(MediumAction action) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(action.AccessToken)) + { + errors.Add(new ValidationError("Access token is required.", nameof(action.AccessToken))); + } + + if (string.IsNullOrWhiteSpace(action.Content)) + { + errors.Add(new ValidationError("Content is required.", nameof(action.Content))); + } + + if (string.IsNullOrWhiteSpace(action.Title)) + { + errors.Add(new ValidationError("Title is required.", nameof(action.Title))); + } + + return Task.FromResult>(errors); + } + public Task> Visit(SlackAction action) { var errors = new List(); diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs index 5b0d07d12..e1710013e 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Rules { public sealed class RuleEnqueuer : IEventConsumer { - private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(2); + private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(10); private readonly IRuleEventRepository ruleEventRepository; private readonly IAppProvider appProvider; private readonly IMemoryCache cache; diff --git a/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs b/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs index 81e475e4d..92cdd4f95 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs @@ -123,9 +123,9 @@ namespace Squidex.Domain.Apps.Entities.Rules } } - public override void ApplyEvent(Envelope @event) + protected override RuleState OnEvent(Envelope @event) { - ApplySnapshot(Snapshot.Apply(@event)); + return Snapshot.Apply(@event); } public Task> GetStateAsync() diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaNestedField.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaNestedField.cs index a20292880..55cd4e6eb 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaNestedField.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaNestedField.cs @@ -5,8 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Domain.Apps.Core.Schemas; - namespace Squidex.Domain.Apps.Entities.Schemas.Commands { public sealed class CreateSchemaNestedField : CreateSchemaFieldBase diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs index e7eb3a28d..2d6924531 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs @@ -24,7 +24,7 @@ using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Schemas { - public class SchemaGrain : SquidexDomainObjectGrain, ISchemaGrain + public sealed class SchemaGrain : SquidexDomainObjectGrain, ISchemaGrain { private readonly IAppProvider appProvider; private readonly FieldRegistry registry; @@ -47,7 +47,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas switch (command) { case AddField addField: - return UpdateReturnAsync(addField, c => + return UpdateReturnAsync(addField, (Func)(c => { GuardSchemaField.CanAdd(Snapshot.SchemaDef, c); @@ -64,8 +64,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas id = ((IArrayField)Snapshot.SchemaDef.FieldsById[c.ParentFieldId.Value]).FieldsByName[c.Name].Id; } - return EntityCreatedResult.Create(id, NewVersion); - }); + return EntityCreatedResult.Create(id, (long)base.Version); + })); case CreateSchema createSchema: return CreateAsync(createSchema, async c => @@ -360,9 +360,9 @@ namespace Squidex.Domain.Apps.Entities.Schemas } } - public override void ApplyEvent(Envelope @event) + protected override SchemaState OnEvent(Envelope @event) { - ApplySnapshot(Snapshot.Apply(@event, registry)); + return Snapshot.Apply(@event, registry); } public Task> GetStateAsync() diff --git a/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj b/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj index 9a624c05e..b2186ae40 100644 --- a/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj +++ b/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj @@ -1,6 +1,6 @@  - netstandard2.0 + netcoreapp2.1 full diff --git a/src/Squidex.Domain.Apps.Entities/SquidexDomainObjectGrainLogSnapshots.cs b/src/Squidex.Domain.Apps.Entities/SquidexDomainObjectGrainLogSnapshots.cs new file mode 100644 index 000000000..425bdc4d6 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/SquidexDomainObjectGrainLogSnapshots.cs @@ -0,0 +1,34 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.States; + +namespace Squidex.Domain.Apps.Entities +{ + public abstract class SquidexDomainObjectGrainLogSnapshots : LogSnapshotDomainObjectGrain where T : IDomainState, new() + { + protected SquidexDomainObjectGrainLogSnapshots(IStore store, ISemanticLog log) + : base(store, log) + { + } + + public override void RaiseEvent(Envelope @event) + { + if (@event.Payload is AppEvent appEvent) + { + @event.SetAppId(appEvent.AppId.Id); + } + + base.RaiseEvent(@event); + } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs b/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs index 2c97f5902..81b95e5fb 100644 --- a/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs +++ b/src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs @@ -13,6 +13,8 @@ namespace Squidex.Domain.Apps.Events.Contents [EventType(nameof(ContentStatusChanged))] public sealed class ContentStatusChanged : ContentEvent { + public StatusChange? Change { get; set; } + public Status Status { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedNestedField.cs b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedNestedField.cs index 331f89513..bb2c22c50 100644 --- a/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedNestedField.cs +++ b/src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedNestedField.cs @@ -5,8 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Domain.Apps.Core.Schemas; - namespace Squidex.Domain.Apps.Events.Schemas { public sealed class SchemaCreatedNestedField : SchemaCreatedFieldBase diff --git a/src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs b/src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs index 91beac110..10f3c2d01 100644 --- a/src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs +++ b/src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs @@ -6,220 +6,63 @@ // ========================================================================== using System; -using System.Collections.Generic; using System.Threading.Tasks; using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Log; -using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.States; -using Squidex.Infrastructure.Tasks; namespace Squidex.Infrastructure.Commands { - public abstract class DomainObjectGrain : GrainOfGuid, IDomainObjectGrain where T : IDomainState, new() + public abstract class DomainObjectGrain : DomainObjectGrainBase where T : IDomainState, new() { - private readonly List> uncomittedEvents = new List>(); private readonly IStore store; - private readonly ISemanticLog log; - private Guid id; private T snapshot = new T { Version = EtagVersion.Empty }; private IPersistence persistence; - public Guid Id - { - get { return id; } - } - - public long Version - { - get { return snapshot.Version; } - } - - public long NewVersion - { - get { return snapshot.Version + uncomittedEvents.Count; } - } - - public T Snapshot + public override T Snapshot { get { return snapshot; } } protected DomainObjectGrain(IStore store, ISemanticLog log) + : base(log) { Guard.NotNull(store, nameof(store)); - Guard.NotNull(log, nameof(log)); this.store = store; - - this.log = log; - } - - public override async Task OnActivateAsync(Guid key) - { - using (log.MeasureInformation(w => w - .WriteProperty("action", "ActivateDomainObject") - .WriteProperty("domainObjectType", GetType().Name) - .WriteProperty("domainObjectKey", key.ToString()))) - { - id = key; - - persistence = store.WithSnapshotsAndEventSourcing(GetType(), id, ApplySnapshot, ApplyEvent); - - await persistence.ReadAsync(); - } - } - - public void RaiseEvent(IEvent @event) - { - RaiseEvent(Envelope.Create(@event)); } - public virtual void RaiseEvent(Envelope @event) + protected sealed override void ApplyEvent(Envelope @event) { - Guard.NotNull(@event, nameof(@event)); - - @event.SetAggregateId(id); - - ApplyEvent(@event); - - uncomittedEvents.Add(@event); - } + var newVersion = Version + 1; - public IReadOnlyList> GetUncomittedEvents() - { - return uncomittedEvents; - } + var snapshotNew = OnEvent(@event); - public void ClearUncommittedEvents() - { - uncomittedEvents.Clear(); + snapshot = OnEvent(@event); + snapshot.Version = newVersion; } - public virtual void ApplySnapshot(T newSnapshot) + protected sealed override void RestorePreviousSnapshot(T previousSnapshot, long previousVersion) { - snapshot = newSnapshot; + snapshot = previousSnapshot; } - public virtual void ApplyEvent(Envelope @event) + protected sealed override Task ReadAsync(Type type, Guid id) { - } + persistence = store.WithSnapshotsAndEventSourcing(GetType(), id, x => snapshot = x, ApplyEvent); - protected Task CreateReturnAsync(TCommand command, Func> handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler, false); - } - - protected Task CreateReturnAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler?.ToAsync(), false); - } - - protected Task CreateAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler.ToDefault(), false); - } - - protected Task CreateAsync(TCommand command, Action handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), false); + return persistence.ReadAsync(); } - protected Task UpdateReturnAsync(TCommand command, Func> handler) where TCommand : class, IAggregateCommand + protected sealed override async Task WriteAsync(Envelope[] events, long previousVersion) { - return InvokeAsync(command, handler, true); - } - - protected Task UpdateReturnAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler?.ToAsync(), true); - } - - protected Task UpdateAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler?.ToDefault(), true); - } - - protected Task UpdateAsync(TCommand command, Action handler) where TCommand : class, IAggregateCommand - { - return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), true); - } - - private async Task InvokeAsync(TCommand command, Func> handler, bool isUpdate) where TCommand : class, IAggregateCommand - { - Guard.NotNull(command, nameof(command)); - - if (command.ExpectedVersion != EtagVersion.Any && command.ExpectedVersion != Version) + if (events.Length > 0) { - throw new DomainObjectVersionException(id.ToString(), GetType(), Version, command.ExpectedVersion); + await persistence.WriteEventsAsync(events); + await persistence.WriteSnapshotAsync(Snapshot); } - - if (isUpdate && Version < 0) - { - try - { - DeactivateOnIdle(); - } - catch (InvalidOperationException) - { - } - - throw new DomainObjectNotFoundException(id.ToString(), GetType()); - } - - if (!isUpdate && Version >= 0) - { - throw new DomainException("Object has already been created."); - } - - var previousSnapshot = snapshot; - try - { - var result = await handler(command); - - var events = uncomittedEvents.ToArray(); - - if (events.Length > 0) - { - snapshot.Version = NewVersion; - - await persistence.WriteEventsAsync(events); - await persistence.WriteSnapshotAsync(snapshot); - } - - if (result == null) - { - if (isUpdate) - { - result = new EntitySavedResult(Version); - } - else - { - result = EntityCreatedResult.Create(id, Version); - } - } - - return result; - } - catch - { - snapshot = previousSnapshot; - - throw; - } - finally - { - uncomittedEvents.Clear(); - } - } - - public async Task> ExecuteAsync(J command) - { - var result = await ExecuteAsync(command.Value); - - return result.AsJ(); } - protected abstract Task ExecuteAsync(IAggregateCommand command); + protected abstract T OnEvent(Envelope @event); } -} +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs b/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs new file mode 100644 index 000000000..042f6d4e5 --- /dev/null +++ b/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs @@ -0,0 +1,202 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Infrastructure.Commands +{ + public abstract class DomainObjectGrainBase : GrainOfGuid, IDomainObjectGrain where T : IDomainState, new() + { + private readonly List> uncomittedEvents = new List>(); + private readonly ISemanticLog log; + private Guid id; + + public Guid Id + { + get { return id; } + } + + public long Version + { + get { return Snapshot.Version; } + } + + public abstract T Snapshot { get; } + + protected DomainObjectGrainBase(ISemanticLog log) + { + Guard.NotNull(log, nameof(log)); + + this.log = log; + } + + public sealed override async Task OnActivateAsync(Guid key) + { + using (log.MeasureInformation(w => w + .WriteProperty("action", "ActivateDomainObject") + .WriteProperty("domainObjectType", GetType().Name) + .WriteProperty("domainObjectKey", key.ToString()))) + { + id = key; + + await ReadAsync(GetType(), id); + } + } + + public void RaiseEvent(IEvent @event) + { + RaiseEvent(Envelope.Create(@event)); + } + + public virtual void RaiseEvent(Envelope @event) + { + Guard.NotNull(@event, nameof(@event)); + + @event.SetAggregateId(id); + + ApplyEvent(@event); + + uncomittedEvents.Add(@event); + } + + public IReadOnlyList> GetUncomittedEvents() + { + return uncomittedEvents; + } + + public void ClearUncommittedEvents() + { + uncomittedEvents.Clear(); + } + + protected Task CreateReturnAsync(TCommand command, Func> handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler, false); + } + + protected Task CreateReturnAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler?.ToAsync(), false); + } + + protected Task CreateAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler.ToDefault(), false); + } + + protected Task CreateAsync(TCommand command, Action handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), false); + } + + protected Task UpdateReturnAsync(TCommand command, Func> handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler, true); + } + + protected Task UpdateReturnAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler?.ToAsync(), true); + } + + protected Task UpdateAsync(TCommand command, Func handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler?.ToDefault(), true); + } + + protected Task UpdateAsync(TCommand command, Action handler) where TCommand : class, IAggregateCommand + { + return InvokeAsync(command, handler?.ToDefault()?.ToAsync(), true); + } + + private async Task InvokeAsync(TCommand command, Func> handler, bool isUpdate) where TCommand : class, IAggregateCommand + { + Guard.NotNull(command, nameof(command)); + + if (command.ExpectedVersion != EtagVersion.Any && command.ExpectedVersion != Version) + { + throw new DomainObjectVersionException(id.ToString(), GetType(), Version, command.ExpectedVersion); + } + + if (isUpdate && Version < 0) + { + try + { + DeactivateOnIdle(); + } + catch (InvalidOperationException) + { + } + + throw new DomainObjectNotFoundException(id.ToString(), GetType()); + } + + if (!isUpdate && Version >= 0) + { + throw new DomainException("Object has already been created."); + } + + var previousSnapshot = Snapshot; + var previousVersion = Version; + try + { + var result = await handler(command); + + var events = uncomittedEvents.ToArray(); + + await WriteAsync(events, previousVersion); + + if (result == null) + { + if (isUpdate) + { + result = new EntitySavedResult(Version); + } + else + { + result = EntityCreatedResult.Create(id, Version); + } + } + + return result; + } + catch + { + RestorePreviousSnapshot(previousSnapshot, previousVersion); + + throw; + } + finally + { + uncomittedEvents.Clear(); + } + } + + protected abstract void RestorePreviousSnapshot(T previousSnapshot, long previousVersion); + + protected abstract void ApplyEvent(Envelope @event); + + protected abstract Task ReadAsync(Type type, Guid id); + + protected abstract Task WriteAsync(Envelope[] events, long previousVersion); + + public async Task> ExecuteAsync(J command) + { + var result = await ExecuteAsync(command.Value); + + return result.AsJ(); + } + + protected abstract Task ExecuteAsync(IAggregateCommand command); + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Commands/LogSnapshotDomainObjectGrain.cs b/src/Squidex.Infrastructure/Commands/LogSnapshotDomainObjectGrain.cs new file mode 100644 index 000000000..417b4f1e3 --- /dev/null +++ b/src/Squidex.Infrastructure/Commands/LogSnapshotDomainObjectGrain.cs @@ -0,0 +1,93 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.States; + +namespace Squidex.Infrastructure.Commands +{ + public abstract class LogSnapshotDomainObjectGrain : DomainObjectGrainBase where T : IDomainState, new() + { + private readonly IStore store; + private readonly List snapshots = new List { new T { Version = EtagVersion.Empty } }; + private IPersistence persistence; + + public override T Snapshot + { + get { return snapshots.Last(); } + } + + protected LogSnapshotDomainObjectGrain(IStore store, ISemanticLog log) + : base(log) + { + Guard.NotNull(log, nameof(log)); + + this.store = store; + } + + public T GetSnapshot(long version) + { + if (version == EtagVersion.Any) + { + return Snapshot; + } + + if (version == EtagVersion.Empty) + { + return snapshots[0]; + } + + if (version >= 0 && version < snapshots.Count - 1) + { + return snapshots[(int)version + 1]; + } + + return default(T); + } + + protected sealed override void ApplyEvent(Envelope @event) + { + var snapshot = OnEvent(@event); + + snapshot.Version = Version + 1; + snapshots.Add(snapshot); + } + + protected sealed override Task ReadAsync(Type type, Guid id) + { + persistence = store.WithEventSourcing(type, id, ApplyEvent); + + return persistence.ReadAsync(); + } + + protected sealed override async Task WriteAsync(Envelope[] events, long previousVersion) + { + if (events.Length > 0) + { + var persistedSnapshots = store.GetSnapshotStore(); + + await persistence.WriteEventsAsync(events); + await persistedSnapshots.WriteAsync(Id, Snapshot, previousVersion, previousVersion + events.Length); + } + } + + protected sealed override void RestorePreviousSnapshot(T previousSnapshot, long previousVersion) + { + while (snapshots.Count > previousVersion + 2) + { + snapshots.RemoveAt(snapshots.Count - 1); + } + } + + protected abstract T OnEvent(Envelope @event); + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Http/DumpFormatter.cs b/src/Squidex.Infrastructure/Http/DumpFormatter.cs index 5f0c0dff1..040e6ddc1 100644 --- a/src/Squidex.Infrastructure/Http/DumpFormatter.cs +++ b/src/Squidex.Infrastructure/Http/DumpFormatter.cs @@ -15,7 +15,17 @@ namespace Squidex.Infrastructure.Http { public static class DumpFormatter { - public static string BuildDump(HttpRequestMessage request, HttpResponseMessage response, string requestBody, string responseBody, TimeSpan elapsed, bool isTimeout) + public static string BuildDump(HttpRequestMessage request, HttpResponseMessage response, string responseBody) + { + return BuildDump(request, response, null, responseBody, TimeSpan.Zero, false); + } + + public static string BuildDump(HttpRequestMessage request, HttpResponseMessage response, string requestBody, string responseBody) + { + return BuildDump(request, response, requestBody, responseBody, TimeSpan.Zero, false); + } + + public static string BuildDump(HttpRequestMessage request, HttpResponseMessage response, string requestBody, string responseBody, TimeSpan elapsed, bool isTimeout = false) { var writer = new StringBuilder(); diff --git a/src/Squidex.Infrastructure/Orleans/GrainOfGuid.cs b/src/Squidex.Infrastructure/Orleans/GrainOfGuid.cs index b014a9c63..0e6e50e22 100644 --- a/src/Squidex.Infrastructure/Orleans/GrainOfGuid.cs +++ b/src/Squidex.Infrastructure/Orleans/GrainOfGuid.cs @@ -14,7 +14,7 @@ namespace Squidex.Infrastructure.Orleans { public abstract class GrainOfGuid : Grain { - public override Task OnActivateAsync() + public sealed override Task OnActivateAsync() { return OnActivateAsync(this.GetPrimaryKey()); } diff --git a/src/Squidex.Infrastructure/Orleans/GrainOfString.cs b/src/Squidex.Infrastructure/Orleans/GrainOfString.cs index 4ed33bacc..3d6b4e089 100644 --- a/src/Squidex.Infrastructure/Orleans/GrainOfString.cs +++ b/src/Squidex.Infrastructure/Orleans/GrainOfString.cs @@ -13,7 +13,7 @@ namespace Squidex.Infrastructure.Orleans { public abstract class GrainOfString : Grain { - public override Task OnActivateAsync() + public sealed override Task OnActivateAsync() { return OnActivateAsync(this.GetPrimaryKeyString()); } diff --git a/src/Squidex.Infrastructure/States/IPersistence.cs b/src/Squidex.Infrastructure/States/IPersistence.cs index c71ff14a2..523a9dd0b 100644 --- a/src/Squidex.Infrastructure/States/IPersistence.cs +++ b/src/Squidex.Infrastructure/States/IPersistence.cs @@ -5,24 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Collections.Generic; -using System.Threading.Tasks; -using Squidex.Infrastructure.EventSourcing; - namespace Squidex.Infrastructure.States { public interface IPersistence : IPersistence { } - - public interface IPersistence - { - long Version { get; } - - Task WriteEventsAsync(IEnumerable> @events); - - Task WriteSnapshotAsync(TState state); - - Task ReadAsync(long expectedVersion = EtagVersion.Any); - } } diff --git a/src/Squidex.Infrastructure/States/IPersistence{TState}.cs b/src/Squidex.Infrastructure/States/IPersistence{TState}.cs new file mode 100644 index 000000000..2b467b302 --- /dev/null +++ b/src/Squidex.Infrastructure/States/IPersistence{TState}.cs @@ -0,0 +1,24 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Threading.Tasks; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Infrastructure.States +{ + public interface IPersistence + { + long Version { get; } + + Task WriteEventsAsync(IEnumerable> @events); + + Task WriteSnapshotAsync(TState state); + + Task ReadAsync(long expectedVersion = EtagVersion.Any); + } +} diff --git a/src/Squidex.Infrastructure/States/IStore.cs b/src/Squidex.Infrastructure/States/IStore.cs index e1e1101bd..e0c65ba83 100644 --- a/src/Squidex.Infrastructure/States/IStore.cs +++ b/src/Squidex.Infrastructure/States/IStore.cs @@ -19,6 +19,8 @@ namespace Squidex.Infrastructure.States IPersistence WithSnapshotsAndEventSourcing(Type owner, TKey key, Func applySnapshot, Func, Task> applyEvent); + ISnapshotStore GetSnapshotStore(); + Task ClearSnapshotsAsync(); } } diff --git a/src/Squidex.Infrastructure/States/Store.cs b/src/Squidex.Infrastructure/States/Store.cs index 89e162a54..0a3e02ff7 100644 --- a/src/Squidex.Infrastructure/States/Store.cs +++ b/src/Squidex.Infrastructure/States/Store.cs @@ -44,7 +44,7 @@ namespace Squidex.Infrastructure.States { Guard.NotNull(key, nameof(key)); - var snapshotStore = (ISnapshotStore)services.GetService(typeof(ISnapshotStore)); + var snapshotStore = GetSnapshotStore(); return new Persistence(key, owner, eventStore, eventDataFormatter, snapshotStore, streamNameResolver, applyEvent); } @@ -53,16 +53,19 @@ namespace Squidex.Infrastructure.States { Guard.NotNull(key, nameof(key)); - var snapshotStore = (ISnapshotStore)services.GetService(typeof(ISnapshotStore)); + var snapshotStore = GetSnapshotStore(); return new Persistence(key, owner, eventStore, eventDataFormatter, snapshotStore, streamNameResolver, mode, applySnapshot, applyEvent); } public Task ClearSnapshotsAsync() { - var snapshotStore = (ISnapshotStore)services.GetService(typeof(ISnapshotStore)); + return GetSnapshotStore().ClearAsync(); + } - return snapshotStore.ClearAsync(); + public ISnapshotStore GetSnapshotStore() + { + return (ISnapshotStore)services.GetService(typeof(ISnapshotStore)); } } } diff --git a/src/Squidex.Infrastructure/Validate.cs b/src/Squidex.Infrastructure/Validate.cs index 8f69aeeca..ae85f0a21 100644 --- a/src/Squidex.Infrastructure/Validate.cs +++ b/src/Squidex.Infrastructure/Validate.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; namespace Squidex.Infrastructure diff --git a/src/Squidex.Infrastructure/ValidationException.cs b/src/Squidex.Infrastructure/ValidationException.cs index 163904187..764e45ab7 100644 --- a/src/Squidex.Infrastructure/ValidationException.cs +++ b/src/Squidex.Infrastructure/ValidationException.cs @@ -21,7 +21,7 @@ namespace Squidex.Infrastructure public IReadOnlyList Errors { - get { return errors; } + get { return errors ?? FallbackErrors; } } public string Summary { get; } diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/MediumActionDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/MediumActionDto.cs new file mode 100644 index 000000000..3d52a238e --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/MediumActionDto.cs @@ -0,0 +1,57 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using NJsonSchema.Annotations; +using Squidex.Domain.Apps.Core.Rules; +using Squidex.Domain.Apps.Core.Rules.Actions; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Rules.Models.Actions +{ + [JsonSchema("Medium")] + public class MediumActionDto : RuleActionDto + { + /// + /// The self issued access token. + /// + [Required] + public string AccessToken { get; set; } + + /// + /// The optional comma separated list of tags. + /// + public string Tags { get; set; } + + /// + /// The title, used for the url. + /// + [Required] + public string Title { get; set; } + + /// + /// The content, either html or markdown. + /// + [Required] + public string Content { get; set; } + + /// + /// The original home of this content, if it was originally published elsewhere. + /// + public string CanonicalUrl { get; set; } + + /// + /// Indicates whether the content is markdown or html. + /// + public bool IsHtml { get; set; } + + public override RuleAction ToAction() + { + return SimpleMapper.Map(this, new MediumAction()); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleActionDtoFactory.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleActionDtoFactory.cs index c2b50b831..664d3f352 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleActionDtoFactory.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleActionDtoFactory.cs @@ -45,6 +45,11 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Converters return SimpleMapper.Map(action, new FastlyActionDto()); } + public RuleActionDto Visit(MediumAction action) + { + return SimpleMapper.Map(action, new MediumActionDto()); + } + public RuleActionDto Visit(SlackAction action) { return SimpleMapper.Map(action, new SlackActionDto()); diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs index dacd359b3..2a16e9639 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs @@ -35,5 +35,20 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers /// Determines whether to handle the event when a content is published. /// public bool SendPublish { get; set; } + + /// + /// Determines whether to handle the event when a content is unpublished. + /// + public bool SendUnpublish { get; set; } + + /// + /// Determines whether to handle the event when a content is archived. + /// + public bool SendArchived { get; set; } + + /// + /// Determines whether to handle the event when a content is restored. + /// + public bool SendRestore { get; set; } } } diff --git a/src/Squidex/Config/Domain/RuleServices.cs b/src/Squidex/Config/Domain/RuleServices.cs index 888d1eee4..3a9ae24d1 100644 --- a/src/Squidex/Config/Domain/RuleServices.cs +++ b/src/Squidex/Config/Domain/RuleServices.cs @@ -18,6 +18,9 @@ namespace Squidex.Config.Domain { public static void AddMyRuleServices(this IServiceCollection services) { + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); @@ -36,6 +39,9 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); diff --git a/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs b/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs index 3dfd01123..dc5375d28 100644 --- a/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs +++ b/src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs @@ -54,7 +54,7 @@ namespace Squidex.Pipeline private static IActionResult OnValidationException(ValidationException ex) { - return ErrorResult(400, new ErrorDto { Message = ex.Summary, Details = ex.Errors.Select(e => e.Message).ToArray() }); + return ErrorResult(400, new ErrorDto { Message = ex.Summary, Details = ex.Errors?.Select(e => e.Message).ToArray() }); } private static IActionResult ErrorResult(int statusCode, ErrorDto error) diff --git a/src/Squidex/Program.cs b/src/Squidex/Program.cs index e3e20d220..2719edd25 100644 --- a/src/Squidex/Program.cs +++ b/src/Squidex/Program.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.IO; -using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; diff --git a/src/Squidex/WebStartup.cs b/src/Squidex/WebStartup.cs index fbaec12fb..815d183fd 100644 --- a/src/Squidex/WebStartup.cs +++ b/src/Squidex/WebStartup.cs @@ -5,9 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Squidex.Areas.Api; diff --git a/src/Squidex/app/features/rules/declarations.ts b/src/Squidex/app/features/rules/declarations.ts index 8c9a3da63..d3e0a1722 100644 --- a/src/Squidex/app/features/rules/declarations.ts +++ b/src/Squidex/app/features/rules/declarations.ts @@ -9,6 +9,7 @@ export * from './pages/rules/actions/algolia-action.component'; export * from './pages/rules/actions/azure-queue-action.component'; export * from './pages/rules/actions/elastic-search-action.component'; export * from './pages/rules/actions/fastly-action.component'; +export * from './pages/rules/actions/medium-action.component'; export * from './pages/rules/actions/slack-action.component'; export * from './pages/rules/actions/webhook-action.component'; export * from './pages/rules/triggers/asset-changed-trigger.component'; diff --git a/src/Squidex/app/features/rules/module.ts b/src/Squidex/app/features/rules/module.ts index d7ab32982..3111c2490 100644 --- a/src/Squidex/app/features/rules/module.ts +++ b/src/Squidex/app/features/rules/module.ts @@ -21,6 +21,7 @@ import { ContentChangedTriggerComponent, ElasticSearchActionComponent, FastlyActionComponent, + MediumActionComponent, RuleEventBadgeClassPipe, RuleEventsPageComponent, RulesPageComponent, @@ -62,6 +63,7 @@ const routes: Routes = [ ContentChangedTriggerComponent, ElasticSearchActionComponent, FastlyActionComponent, + MediumActionComponent, RuleEventBadgeClassPipe, RuleEventsPageComponent, RulesPageComponent, diff --git a/src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.html b/src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.html new file mode 100644 index 000000000..b5861bcef --- /dev/null +++ b/src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.html @@ -0,0 +1,84 @@ +

Post to Medium

+ +
+
+ + +
+ + + + + + The self issued access token. Can be created under https://medium.com/me/settings. + +
+
+ +
+ + +
+ + + + + + The title of the post. Note that this title is used for SEO and when rendering the post as a listing. + +
+
+ +
+ + +
+ + + + + + The body of the post, in a valid, semantic, HTML fragment, or Markdown. + +
+
+ +
+
+
+ + +
+
+
+ +
+ + +
+ + + + + + The original home of this content, if it was originally published elsewhere. + +
+
+ +
+ + +
+ + + + + + Comma-separated list of tags. + +
+
+
\ No newline at end of file diff --git a/src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.scss b/src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.scss new file mode 100644 index 000000000..756609665 --- /dev/null +++ b/src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.scss @@ -0,0 +1,6 @@ +@import '_vars'; +@import '_mixins'; + +textarea { + height: 150px; +} \ No newline at end of file diff --git a/src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.ts b/src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.ts new file mode 100644 index 000000000..a29f274df --- /dev/null +++ b/src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.ts @@ -0,0 +1,51 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { Component, Input, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; + +@Component({ + selector: 'sqx-medium-action', + styleUrls: ['./medium-action.component.scss'], + templateUrl: './medium-action.component.html' +}) +export class MediumActionComponent implements OnInit { + @Input() + public action: any; + + @Input() + public actionForm: FormGroup; + + @Input() + public actionFormSubmitted = false; + + public ngOnInit() { + this.actionForm.setControl('accessToken', + new FormControl(this.action.accessToken || '', [ + Validators.required + ])); + + this.actionForm.setControl('title', + new FormControl(this.action.title || '', [ + Validators.required + ])); + + this.actionForm.setControl('content', + new FormControl(this.action.content || '', [ + Validators.required + ])); + + this.actionForm.setControl('canonicalUrl', + new FormControl(this.action.canonicalUrl || '')); + + this.actionForm.setControl('tags', + new FormControl(this.action.tags || '')); + + this.actionForm.setControl('isHtml', + new FormControl(this.action.isHtml || false)); + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html index f15755dc0..d0b979c1f 100644 --- a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html +++ b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html @@ -21,16 +21,18 @@ - - - - - - - {{ruleTriggers[trigger].name}} - - - +
+
+
+ + + + + {{ruleTriggers[trigger].name}} + +
+
+
@@ -54,16 +56,18 @@
- - - - - - - {{ruleActions[action].name}} - - - +
+
+
+ + + + + {{ruleActions[action].name}} + +
+
+
@@ -96,6 +100,13 @@ [actionFormSubmitted]="actionForm.submitted | async"> + + + + + + + @@ -31,6 +34,15 @@
Published
+ +
Unpublished
+ + +
Archived
+ + +
Restored
+ @@ -53,6 +65,15 @@ + + + + + + + + +