Browse Source

Notifications for jobs. (#1063)

pull/1065/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
9d5ec01d57
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 14
      backend/i18n/source/backend_en.json
  2. 7
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs
  3. 2
      backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs
  4. 51
      backend/src/Squidex.Domain.Apps.Entities/Collaboration/CommentCollaborationHandler.cs
  5. 5
      backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs
  6. 58
      backend/src/Squidex.Domain.Apps.Entities/History/NotifoService.cs
  7. 28
      backend/src/Squidex.Domain.Apps.Entities/Jobs/JobProcessor.cs
  8. 2
      backend/src/Squidex.Domain.Apps.Entities/Jobs/JobRequest.cs
  9. 7
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/DefaultRuleRunnerService.cs
  10. 3
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/IRuleRunnerService.cs
  11. 16
      backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs
  12. 2
      backend/src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs
  13. 36
      backend/src/Squidex.Shared/Texts.fr.resx
  14. 36
      backend/src/Squidex.Shared/Texts.it.resx
  15. 36
      backend/src/Squidex.Shared/Texts.nl.resx
  16. 36
      backend/src/Squidex.Shared/Texts.pt.resx
  17. 36
      backend/src/Squidex.Shared/Texts.resx
  18. 36
      backend/src/Squidex.Shared/Texts.zh.resx
  19. 2
      backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  20. 66
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Collaboration/CommentCollaborationHandlerTests.cs
  21. 2
      frontend/src/app/shell/pages/internal/notifications-menu.component.html
  22. 2
      frontend/src/app/shell/pages/internal/notifications-menu.component.ts

14
backend/i18n/source/backend_en.json

@ -262,15 +262,17 @@
"history.teams.planChanged": "changed plan to {[Plan]}.", "history.teams.planChanged": "changed plan to {[Plan]}.",
"history.teams.planReset": "resetted plan.", "history.teams.planReset": "resetted plan.",
"history.teams.updated": "updated general settings and renamed name to {[Name]}.", "history.teams.updated": "updated general settings and renamed name to {[Name]}.",
"job.backup": "Backup",
"job.restore": "Restore",
"job.ruleRun": "Replay Rule.",
"job.ruleRunNamed": "Replay Rule '{name}'.",
"job.ruleRunNamedSnapshot": "Replay Rule '{name}' from states.",
"job.ruleRunSnapshot": "Replay Rule from states",
"jobs.alreadyRunning": "Another job is already running.", "jobs.alreadyRunning": "Another job is already running.",
"jobs.backup": "Backup",
"jobs.invalidTaskName": "Invalid task name", "jobs.invalidTaskName": "Invalid task name",
"jobs.maxReached": "You cannot have more than {max} backups.", "jobs.maxReached": "You cannot have more than {max} backups.",
"jobs.notifyFailed": "Your job '{job}' failed to completed.",
"jobs.notifySuccess": "Your job '{job}' has been completed successfully.",
"jobs.restore": "Restore",
"jobs.ruleRun": "Replay Rule.",
"jobs.ruleRunNamed": "Replay Rule '{name}'.",
"jobs.ruleRunNamedSnapshot": "Replay Rule '{name}' from states.",
"jobs.ruleRunSnapshot": "Replay Rule from states",
"login.githubPrivateEmail": "Your email address is set to private in Github. Please set it to public to use Github login.", "login.githubPrivateEmail": "Your email address is set to private in Github. Please set it to public to use Github login.",
"schemas.dateTimeCalculatedDefaultAndDefaultError": "Calculated default value and default value cannot be used together.", "schemas.dateTimeCalculatedDefaultAndDefaultError": "Calculated default value and default value cannot be used together.",
"schemas.duplicateFieldName": "Field '{field}' has been added twice.", "schemas.duplicateFieldName": "Field '{field}' has been added twice.",

7
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupJob.cs

@ -57,7 +57,10 @@ public sealed class BackupJob : IJobRunner
{ {
[ArgAppId] = app.Id.ToString(), [ArgAppId] = app.Id.ToString(),
[ArgAppName] = app.Name [ArgAppName] = app.Name
}); }) with
{
AppId = app.NamedId()
};
} }
public Task DownloadAsync(Job state, Stream stream, public Task DownloadAsync(Job state, Stream stream,
@ -81,7 +84,7 @@ public sealed class BackupJob : IJobRunner
context.Job.File = new JobFile($"backup-{appName}-{context.Job.Started:yyyy-MM-dd_HH-mm-ss}.zip", "application/zip"); context.Job.File = new JobFile($"backup-{appName}-{context.Job.Started:yyyy-MM-dd_HH-mm-ss}.zip", "application/zip");
// Use a readable name to describe the job. // Use a readable name to describe the job.
context.Job.Description = T.Get("job.backup"); context.Job.Description = T.Get("jobs.backup");
var handlers = backupHandlerFactory.CreateMany(); var handlers = backupHandlerFactory.CreateMany();

2
backend/src/Squidex.Domain.Apps.Entities/Backup/RestoreJob.cs

@ -107,7 +107,7 @@ public sealed class RestoreJob : IJobRunner
}; };
// Use a readable name to describe the job. // Use a readable name to describe the job.
context.Job.Description = T.Get("job.restore"); context.Job.Description = T.Get("jobs.restore");
try try
{ {

51
backend/src/Squidex.Domain.Apps.Entities/Collaboration/CommentCollaborationHandler.cs

@ -96,7 +96,7 @@ public sealed partial class CommentCollaborationHandler : IDocumentCallback, ICo
public ValueTask OnDocumentLoadedAsync(DocumentLoadEvent @event) public ValueTask OnDocumentLoadedAsync(DocumentLoadEvent @event)
{ {
if (!IsResourceDocument(@event.Context.DocumentName, out var appId, out var resourceId)) if (!IsResourceOrUserDocument(@event.Context.DocumentName, out var appId, out var resourceId))
{ {
return default; return default;
} }
@ -239,26 +239,55 @@ public sealed partial class CommentCollaborationHandler : IDocumentCallback, ICo
return $"apps/{appId}/{resourceId}"; return $"apps/{appId}/{resourceId}";
} }
private static bool IsResourceDocument(string name, out NamedId<DomainId> appId, out DomainId resourceId) private static bool IsResourceOrUserDocument(string name, out NamedId<DomainId> appId, out DomainId resourceId)
{ {
resourceId = default; resourceId = default;
if (!name.StartsWith("apps", StringComparison.Ordinal)) // Result can be null, if the method returns null.
appId = default!;
static bool IsResourceDocument(string name, ref NamedId<DomainId> appId, ref DomainId resourceId)
{ {
appId = default!; var parts = name.Split('/');
return false;
if (parts.Length < 3 || !NamedId<DomainId>.TryParse(parts[1], DomainId.TryParse, out appId!))
{
return false;
}
// Assume tha tour ID could also have slashes.
resourceId = DomainId.Create(string.Join('/', parts.Skip(2)));
return true;
} }
var parts = name.Split('/'); static bool IsUserDocument(string name, ref NamedId<DomainId> appId, ref DomainId resourceId)
{
var parts = name.Split('/');
if (parts.Length < 2)
{
return false;
}
// Use a dummy value for the app ID.
appId = CommentCreated.NoApp;
// Assume tha tour ID could also have slashes.
resourceId = DomainId.Create(string.Join('/', parts.Skip(1)));
return true;
}
if (name.StartsWith("apps/", StringComparison.Ordinal))
{
return IsResourceDocument(name, ref appId, ref resourceId);
}
if (parts.Length < 3 || !NamedId<DomainId>.TryParse(parts[1], DomainId.TryParse, out appId!)) if (name.StartsWith("users/", StringComparison.Ordinal))
{ {
appId = default!; return IsUserDocument(name, ref appId, ref resourceId);
return false;
} }
resourceId = DomainId.Create(string.Join('/', parts.Skip(2))); return false;
return true;
} }
[GeneratedRegex(@"@(?=.{1,64}@)[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*", RegexOptions.Compiled | RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 100)] [GeneratedRegex(@"@(?=.{1,64}@)[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*", RegexOptions.Compiled | RegexOptions.ExplicitCapture, matchTimeoutMilliseconds: 100)]

5
backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs

@ -7,6 +7,7 @@
using Squidex.Domain.Apps.Entities.History.Repositories; using Squidex.Domain.Apps.Entities.History.Repositories;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Domain.Apps.Events.Teams; using Squidex.Domain.Apps.Events.Teams;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
@ -64,6 +65,10 @@ public sealed class HistoryService : IHistoryService, IEventConsumer
{ {
switch (@event.Payload) switch (@event.Payload)
{ {
case CommentCreated:
targets.Add((@event, null));
break;
case AppEvent appEvent: case AppEvent appEvent:
{ {
var historyEvent = await CreateEvent(appEvent.AppId.Id, appEvent.Actor, @event); var historyEvent = await CreateEvent(appEvent.AppId.Id, appEvent.Actor, @event);

58
backend/src/Squidex.Domain.Apps.Entities/History/NotifoService.cs

@ -237,13 +237,17 @@ public class NotifoService : IUserEvents
private IEnumerable<PublishDto> CreateRequests(Envelope<IEvent> @event, HistoryEvent? historyEvent) private IEnumerable<PublishDto> CreateRequests(Envelope<IEvent> @event, HistoryEvent? historyEvent)
{ {
if (@event.Payload is CommentCreated { Mentions.Length: > 0 } comment) if (@event.Payload is CommentCreated { Mentions.Length: > 0, AppId: not null } comment)
{ {
foreach (var userId in comment.Mentions) foreach (var userId in comment.Mentions)
{ {
yield return CreateMentionRequest(comment, userId); yield return CreateMentionRequest(comment, userId);
} }
} }
else if (@event.Payload is CommentCreated notification && notification.AppId == CommentCreated.NoApp)
{
yield return CreateMentionRequest(notification, notification.CommentsId.ToString());
}
else if (historyEvent != null && @event.Payload is AppEvent appEvent) else if (historyEvent != null && @event.Payload is AppEvent appEvent)
{ {
yield return CreateHistoryRequest(historyEvent, appEvent); yield return CreateHistoryRequest(historyEvent, appEvent);
@ -262,25 +266,17 @@ public class NotifoService : IUserEvents
publishRequest.Properties.Add(key, value); publishRequest.Properties.Add(key, value);
} }
if (payload is AppEvent appEvent)
{
publishRequest.Properties["SquidexApp"] = appEvent.AppId.Name;
}
if (payload is SquidexEvent squidexEvent) if (payload is SquidexEvent squidexEvent)
{ {
SetUser(squidexEvent, publishRequest); SetUser(squidexEvent, publishRequest);
} }
if (payload is AppEvent appEvent2) if (payload is AppEvent appEvent)
{ {
publishRequest.Topic = BuildTopic(GetAppPrefix(appEvent2), historyEvent); publishRequest.Properties["SquidexApp"] = appEvent.AppId.Name;
} }
if (payload is TeamEvent teamEvent) SetTopic(historyEvent, payload, publishRequest);
{
publishRequest.Topic = BuildTopic(GetTeamPrefix(teamEvent), historyEvent);
}
if (payload is ContentEvent @event and not ContentDeleted) if (payload is ContentEvent @event and not ContentDeleted)
{ {
@ -294,26 +290,46 @@ public class NotifoService : IUserEvents
return publishRequest; return publishRequest;
} }
private static void SetTopic(HistoryEvent historyEvent, IEvent payload, PublishDto publishRequest)
{
if (payload is AppEvent appEvent)
{
publishRequest.Topic = BuildTopic(GetAppPrefix(appEvent), historyEvent);
}
else if (payload is TeamEvent teamEvent)
{
publishRequest.Topic = BuildTopic(GetTeamPrefix(teamEvent), historyEvent);
}
}
private static PublishDto CreateMentionRequest(CommentCreated comment, string userId) private static PublishDto CreateMentionRequest(CommentCreated comment, string userId)
{ {
var publishRequest = new PublishDto var publishRequest = new PublishDto
{ {
Topic = $"users/{userId}" Topic = $"users/{userId}",
Preformatted = new NotificationFormattingDto
{
Subject =
{
["en"] = comment.Text
}
}
}; };
publishRequest.Properties["SquidexApp"] = comment.AppId.Name; if (comment.AppId != null)
publishRequest.Preformatted = new NotificationFormattingDto
{ {
Subject = publishRequest.Properties = new NotificationProperties
{ {
["en"] = comment.Text ["SquidexApp"] = comment.AppId.Name
} };
}; }
if (comment.Url?.IsAbsoluteUri == true) if (comment.Url?.IsAbsoluteUri == true)
{ {
publishRequest.Preformatted.LinkUrl["en"] = comment.Url.ToString(); publishRequest.Preformatted.LinkUrl = new LocalizedText()
{
["en"] = comment.Url.ToString()
};
} }
SetUser(comment, publishRequest); SetUser(comment, publishRequest);

28
backend/src/Squidex.Domain.Apps.Entities/Jobs/JobProcessor.cs

@ -8,6 +8,8 @@
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NodaTime; using NodaTime;
using Squidex.Caching; using Squidex.Caching;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Entities.Collaboration;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Tasks; using Squidex.Infrastructure.Tasks;
@ -20,6 +22,8 @@ public sealed class JobProcessor
private readonly DomainId ownerId; private readonly DomainId ownerId;
private readonly IEnumerable<IJobRunner> runners; private readonly IEnumerable<IJobRunner> runners;
private readonly ILocalCache localCache; private readonly ILocalCache localCache;
private readonly ICollaborationService collaboration;
private readonly IUrlGenerator urlGenerator;
private readonly ILogger<JobProcessor> log; private readonly ILogger<JobProcessor> log;
private readonly SimpleState<JobsState> state; private readonly SimpleState<JobsState> state;
private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1); private readonly ReentrantScheduler scheduler = new ReentrantScheduler(1);
@ -30,12 +34,16 @@ public sealed class JobProcessor
public JobProcessor(DomainId ownerId, public JobProcessor(DomainId ownerId,
IEnumerable<IJobRunner> runners, IEnumerable<IJobRunner> runners,
ILocalCache localCache, ILocalCache localCache,
ICollaborationService collaboration,
IPersistenceFactory<JobsState> persistenceFactory, IPersistenceFactory<JobsState> persistenceFactory,
IUrlGenerator urlGenerator,
ILogger<JobProcessor> log) ILogger<JobProcessor> log)
{ {
this.ownerId = ownerId; this.ownerId = ownerId;
this.runners = runners; this.runners = runners;
this.localCache = localCache; this.localCache = localCache;
this.collaboration = collaboration;
this.urlGenerator = urlGenerator;
this.log = log; this.log = log;
state = new SimpleState<JobsState>(persistenceFactory, GetType(), ownerId); state = new SimpleState<JobsState>(persistenceFactory, GetType(), ownerId);
@ -148,6 +156,13 @@ public sealed class JobProcessor
try try
{ {
await ProcessAsync(context, runner, context.CancellationToken); await ProcessAsync(context, runner, context.CancellationToken);
await NotifyAsync(request, context, "jobs.notifySuccess");
}
catch
{
await NotifyAsync(request, context, "jobs.notifyFailed");
throw;
} }
finally finally
{ {
@ -158,6 +173,19 @@ public sealed class JobProcessor
}, ct); }, ct);
} }
private async Task NotifyAsync(JobRequest request, JobRunContext context, string text)
{
if (request.AppId == null || request.Actor.IsClient)
{
return;
}
var notificationText = T.Get(text, new { job = context.Job.Description });
var notificationUrl = new Uri(urlGenerator.JobsUI(request.AppId));
await collaboration.NotifyAsync(request.Actor.Identifier, notificationText, request.Actor, notificationUrl, false, default);
}
private async Task ProcessAsync(JobRunContext context, IJobRunner runner, private async Task ProcessAsync(JobRunContext context, IJobRunner runner,
CancellationToken ct) CancellationToken ct)
{ {

2
backend/src/Squidex.Domain.Apps.Entities/Jobs/JobRequest.cs

@ -14,6 +14,8 @@ namespace Squidex.Domain.Apps.Entities.Jobs;
public record struct JobRequest(RefToken Actor, string TaskName, ReadonlyDictionary<string, string> Arguments) public record struct JobRequest(RefToken Actor, string TaskName, ReadonlyDictionary<string, string> Arguments)
{ {
public NamedId<DomainId>? AppId { get; set; }
public static JobRequest Create(RefToken actor, string taskName, Dictionary<string, string>? arguments = null) public static JobRequest Create(RefToken actor, string taskName, Dictionary<string, string>? arguments = null)
{ {
var args = arguments?.ToReadonlyDictionary() ?? ReadonlyDictionary.Empty<string, string>(); var args = arguments?.ToReadonlyDictionary() ?? ReadonlyDictionary.Empty<string, string>();

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

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
@ -121,12 +122,12 @@ public sealed class DefaultRuleRunnerService : IRuleRunnerService
return jobService.CancelAsync(appId, taskName, ct); return jobService.CancelAsync(appId, taskName, ct);
} }
public Task RunAsync(RefToken actor, DomainId appId, DomainId ruleId, bool fromSnapshots = false, public Task RunAsync(RefToken actor, App app, DomainId ruleId, bool fromSnapshots = false,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var job = RuleRunnerJob.BuildRequest(actor, ruleId, fromSnapshots); var job = RuleRunnerJob.BuildRequest(actor, app, ruleId, fromSnapshots);
return jobService.StartAsync(appId, job, ct); return jobService.StartAsync(app.Id, job, ct);
} }
public async Task<DomainId?> GetRunningRuleIdAsync(DomainId appId, public async Task<DomainId?> GetRunningRuleIdAsync(DomainId appId,

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -18,7 +19,7 @@ public interface IRuleRunnerService
Task<List<SimulatedRuleEvent>> SimulateAsync(Rule rule, Task<List<SimulatedRuleEvent>> SimulateAsync(Rule rule,
CancellationToken ct = default); CancellationToken ct = default);
Task RunAsync(RefToken actor, DomainId appId, DomainId ruleId, bool fromSnapshots = false, Task RunAsync(RefToken actor, App app, DomainId ruleId, bool fromSnapshots = false,
CancellationToken ct = default); CancellationToken ct = default);
Task CancelAsync(DomainId appId, Task CancelAsync(DomainId appId,

16
backend/src/Squidex.Domain.Apps.Entities/Rules/Runner/RuleRunnerJob.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules; using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Entities.Jobs; using Squidex.Domain.Apps.Entities.Jobs;
@ -66,7 +67,7 @@ public sealed class RuleRunnerJob : IJobRunner
return DomainId.Create(ruleId); return DomainId.Create(ruleId);
} }
public static JobRequest BuildRequest(RefToken actor, DomainId ruleId, bool snapshot) public static JobRequest BuildRequest(RefToken actor, App app, DomainId ruleId, bool snapshot)
{ {
return JobRequest.Create( return JobRequest.Create(
actor, actor,
@ -75,7 +76,10 @@ public sealed class RuleRunnerJob : IJobRunner
{ {
[ArgRuleId] = ruleId.ToString(), [ArgRuleId] = ruleId.ToString(),
[ArgSnapshot] = snapshot.ToString() [ArgSnapshot] = snapshot.ToString()
}); }) with
{
AppId = app.NamedId()
};
} }
public async Task RunAsync(JobRunContext context, public async Task RunAsync(JobRunContext context,
@ -118,16 +122,16 @@ public sealed class RuleRunnerJob : IJobRunner
if (!string.IsNullOrWhiteSpace(rule.Name)) if (!string.IsNullOrWhiteSpace(rule.Name))
{ {
var key = fromSnapshot ? var key = fromSnapshot ?
"job.ruleRunNamedSnapshot" : "jobs.ruleRunNamedSnapshot" :
"job.ruleRunName"; "jobs.ruleRunName";
run.Job.Description = T.Get(key, new { name = rule.Name }); run.Job.Description = T.Get(key, new { name = rule.Name });
} }
else else
{ {
var key = fromSnapshot ? var key = fromSnapshot ?
"job.ruleRunSnapshot" : "jobs.ruleRunSnapshot" :
"job.ruleRun"; "jobs.ruleRun";
run.Job.Description = T.Get(key); run.Job.Description = T.Get(key);
} }

2
backend/src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs

@ -13,6 +13,8 @@ namespace Squidex.Domain.Apps.Events.Comments;
[EventType(nameof(CommentCreated))] [EventType(nameof(CommentCreated))]
public sealed class CommentCreated : AppEvent public sealed class CommentCreated : AppEvent
{ {
public static readonly NamedId<DomainId> NoApp = NamedId.Of(DomainId.NewGuid(), "no-app");
public DomainId CommentsId { get; set; } public DomainId CommentsId { get; set; }
public DomainId CommentId { get; set; } public DomainId CommentId { get; set; }

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

@ -871,33 +871,39 @@
<data name="history.teams.updated" xml:space="preserve"> <data name="history.teams.updated" xml:space="preserve">
<value>paramètres généraux mis à jour et nom renommé en {[Name]}.</value> <value>paramètres généraux mis à jour et nom renommé en {[Name]}.</value>
</data> </data>
<data name="job.backup" xml:space="preserve"> <data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.backup" xml:space="preserve">
<value>Backup</value> <value>Backup</value>
</data> </data>
<data name="job.restore" xml:space="preserve"> <data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="jobs.notifyFailed" xml:space="preserve">
<value>Your job '{job}' failed to completed.</value>
</data>
<data name="jobs.notifySuccess" xml:space="preserve">
<value>Your job '{job}' has been completed successfully.</value>
</data>
<data name="jobs.restore" xml:space="preserve">
<value>Restore</value> <value>Restore</value>
</data> </data>
<data name="job.ruleRun" xml:space="preserve"> <data name="jobs.ruleRun" xml:space="preserve">
<value>Replay Rule.</value> <value>Replay Rule.</value>
</data> </data>
<data name="job.ruleRunNamed" xml:space="preserve"> <data name="jobs.ruleRunNamed" xml:space="preserve">
<value>Replay Rule '{name}'.</value> <value>Replay Rule '{name}'.</value>
</data> </data>
<data name="job.ruleRunNamedSnapshot" xml:space="preserve"> <data name="jobs.ruleRunNamedSnapshot" xml:space="preserve">
<value>Replay Rule '{name}' from states.</value> <value>Replay Rule '{name}' from states.</value>
</data> </data>
<data name="job.ruleRunSnapshot" xml:space="preserve"> <data name="jobs.ruleRunSnapshot" xml:space="preserve">
<value>Replay Rule from states</value> <value>Replay Rule from states</value>
</data> </data>
<data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="login.githubPrivateEmail" xml:space="preserve"> <data name="login.githubPrivateEmail" xml:space="preserve">
<value>Votre adresse e-mail est définie sur privé dans Github. Veuillez le définir sur public pour utiliser la connexion Github.</value> <value>Votre adresse e-mail est définie sur privé dans Github. Veuillez le définir sur public pour utiliser la connexion Github.</value>
</data> </data>

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

@ -871,33 +871,39 @@
<data name="history.teams.updated" xml:space="preserve"> <data name="history.teams.updated" xml:space="preserve">
<value>updated general settings and renamed name to {[Name]}.</value> <value>updated general settings and renamed name to {[Name]}.</value>
</data> </data>
<data name="job.backup" xml:space="preserve"> <data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.backup" xml:space="preserve">
<value>Backup</value> <value>Backup</value>
</data> </data>
<data name="job.restore" xml:space="preserve"> <data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="jobs.notifyFailed" xml:space="preserve">
<value>Your job '{job}' failed to completed.</value>
</data>
<data name="jobs.notifySuccess" xml:space="preserve">
<value>Your job '{job}' has been completed successfully.</value>
</data>
<data name="jobs.restore" xml:space="preserve">
<value>Restore</value> <value>Restore</value>
</data> </data>
<data name="job.ruleRun" xml:space="preserve"> <data name="jobs.ruleRun" xml:space="preserve">
<value>Replay Rule.</value> <value>Replay Rule.</value>
</data> </data>
<data name="job.ruleRunNamed" xml:space="preserve"> <data name="jobs.ruleRunNamed" xml:space="preserve">
<value>Replay Rule '{name}'.</value> <value>Replay Rule '{name}'.</value>
</data> </data>
<data name="job.ruleRunNamedSnapshot" xml:space="preserve"> <data name="jobs.ruleRunNamedSnapshot" xml:space="preserve">
<value>Replay Rule '{name}' from states.</value> <value>Replay Rule '{name}' from states.</value>
</data> </data>
<data name="job.ruleRunSnapshot" xml:space="preserve"> <data name="jobs.ruleRunSnapshot" xml:space="preserve">
<value>Replay Rule from states</value> <value>Replay Rule from states</value>
</data> </data>
<data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="login.githubPrivateEmail" xml:space="preserve"> <data name="login.githubPrivateEmail" xml:space="preserve">
<value>Il tuo indirizzo email è impostato su privato in Github. Impostalo come pubblico per poter utilizzare il login Github.</value> <value>Il tuo indirizzo email è impostato su privato in Github. Impostalo come pubblico per poter utilizzare il login Github.</value>
</data> </data>

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

@ -871,33 +871,39 @@
<data name="history.teams.updated" xml:space="preserve"> <data name="history.teams.updated" xml:space="preserve">
<value>updated general settings and renamed name to {[Name]}.</value> <value>updated general settings and renamed name to {[Name]}.</value>
</data> </data>
<data name="job.backup" xml:space="preserve"> <data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.backup" xml:space="preserve">
<value>Backup</value> <value>Backup</value>
</data> </data>
<data name="job.restore" xml:space="preserve"> <data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="jobs.notifyFailed" xml:space="preserve">
<value>Your job '{job}' failed to completed.</value>
</data>
<data name="jobs.notifySuccess" xml:space="preserve">
<value>Your job '{job}' has been completed successfully.</value>
</data>
<data name="jobs.restore" xml:space="preserve">
<value>Restore</value> <value>Restore</value>
</data> </data>
<data name="job.ruleRun" xml:space="preserve"> <data name="jobs.ruleRun" xml:space="preserve">
<value>Replay Rule.</value> <value>Replay Rule.</value>
</data> </data>
<data name="job.ruleRunNamed" xml:space="preserve"> <data name="jobs.ruleRunNamed" xml:space="preserve">
<value>Replay Rule '{name}'.</value> <value>Replay Rule '{name}'.</value>
</data> </data>
<data name="job.ruleRunNamedSnapshot" xml:space="preserve"> <data name="jobs.ruleRunNamedSnapshot" xml:space="preserve">
<value>Replay Rule '{name}' from states.</value> <value>Replay Rule '{name}' from states.</value>
</data> </data>
<data name="job.ruleRunSnapshot" xml:space="preserve"> <data name="jobs.ruleRunSnapshot" xml:space="preserve">
<value>Replay Rule from states</value> <value>Replay Rule from states</value>
</data> </data>
<data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="login.githubPrivateEmail" xml:space="preserve"> <data name="login.githubPrivateEmail" xml:space="preserve">
<value>Jouw e-mailadres is ingesteld op privé in Github. Stel het in op openbaar om Github-login te gebruiken.</value> <value>Jouw e-mailadres is ingesteld op privé in Github. Stel het in op openbaar om Github-login te gebruiken.</value>
</data> </data>

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

@ -871,33 +871,39 @@
<data name="history.teams.updated" xml:space="preserve"> <data name="history.teams.updated" xml:space="preserve">
<value>actualizadas configurações gerais e renomeado para {[Name]}.</value> <value>actualizadas configurações gerais e renomeado para {[Name]}.</value>
</data> </data>
<data name="job.backup" xml:space="preserve"> <data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.backup" xml:space="preserve">
<value>Backup</value> <value>Backup</value>
</data> </data>
<data name="job.restore" xml:space="preserve"> <data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="jobs.notifyFailed" xml:space="preserve">
<value>Your job '{job}' failed to completed.</value>
</data>
<data name="jobs.notifySuccess" xml:space="preserve">
<value>Your job '{job}' has been completed successfully.</value>
</data>
<data name="jobs.restore" xml:space="preserve">
<value>Restore</value> <value>Restore</value>
</data> </data>
<data name="job.ruleRun" xml:space="preserve"> <data name="jobs.ruleRun" xml:space="preserve">
<value>Replay Rule.</value> <value>Replay Rule.</value>
</data> </data>
<data name="job.ruleRunNamed" xml:space="preserve"> <data name="jobs.ruleRunNamed" xml:space="preserve">
<value>Replay Rule '{name}'.</value> <value>Replay Rule '{name}'.</value>
</data> </data>
<data name="job.ruleRunNamedSnapshot" xml:space="preserve"> <data name="jobs.ruleRunNamedSnapshot" xml:space="preserve">
<value>Replay Rule '{name}' from states.</value> <value>Replay Rule '{name}' from states.</value>
</data> </data>
<data name="job.ruleRunSnapshot" xml:space="preserve"> <data name="jobs.ruleRunSnapshot" xml:space="preserve">
<value>Replay Rule from states</value> <value>Replay Rule from states</value>
</data> </data>
<data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="login.githubPrivateEmail" xml:space="preserve"> <data name="login.githubPrivateEmail" xml:space="preserve">
<value>O seu Email é privado no Github. Altere para publico no Github e tente novamente.</value> <value>O seu Email é privado no Github. Altere para publico no Github e tente novamente.</value>
</data> </data>

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

@ -871,33 +871,39 @@
<data name="history.teams.updated" xml:space="preserve"> <data name="history.teams.updated" xml:space="preserve">
<value>updated general settings and renamed name to {[Name]}.</value> <value>updated general settings and renamed name to {[Name]}.</value>
</data> </data>
<data name="job.backup" xml:space="preserve"> <data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.backup" xml:space="preserve">
<value>Backup</value> <value>Backup</value>
</data> </data>
<data name="job.restore" xml:space="preserve"> <data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="jobs.notifyFailed" xml:space="preserve">
<value>Your job '{job}' failed to completed.</value>
</data>
<data name="jobs.notifySuccess" xml:space="preserve">
<value>Your job '{job}' has been completed successfully.</value>
</data>
<data name="jobs.restore" xml:space="preserve">
<value>Restore</value> <value>Restore</value>
</data> </data>
<data name="job.ruleRun" xml:space="preserve"> <data name="jobs.ruleRun" xml:space="preserve">
<value>Replay Rule.</value> <value>Replay Rule.</value>
</data> </data>
<data name="job.ruleRunNamed" xml:space="preserve"> <data name="jobs.ruleRunNamed" xml:space="preserve">
<value>Replay Rule '{name}'.</value> <value>Replay Rule '{name}'.</value>
</data> </data>
<data name="job.ruleRunNamedSnapshot" xml:space="preserve"> <data name="jobs.ruleRunNamedSnapshot" xml:space="preserve">
<value>Replay Rule '{name}' from states.</value> <value>Replay Rule '{name}' from states.</value>
</data> </data>
<data name="job.ruleRunSnapshot" xml:space="preserve"> <data name="jobs.ruleRunSnapshot" xml:space="preserve">
<value>Replay Rule from states</value> <value>Replay Rule from states</value>
</data> </data>
<data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="login.githubPrivateEmail" xml:space="preserve"> <data name="login.githubPrivateEmail" xml:space="preserve">
<value>Your email address is set to private in Github. Please set it to public to use Github login.</value> <value>Your email address is set to private in Github. Please set it to public to use Github login.</value>
</data> </data>

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

@ -871,33 +871,39 @@
<data name="history.teams.updated" xml:space="preserve"> <data name="history.teams.updated" xml:space="preserve">
<value>updated general settings and renamed name to {[Name]}.</value> <value>updated general settings and renamed name to {[Name]}.</value>
</data> </data>
<data name="job.backup" xml:space="preserve"> <data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.backup" xml:space="preserve">
<value>Backup</value> <value>Backup</value>
</data> </data>
<data name="job.restore" xml:space="preserve"> <data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="jobs.notifyFailed" xml:space="preserve">
<value>Your job '{job}' failed to completed.</value>
</data>
<data name="jobs.notifySuccess" xml:space="preserve">
<value>Your job '{job}' has been completed successfully.</value>
</data>
<data name="jobs.restore" xml:space="preserve">
<value>Restore</value> <value>Restore</value>
</data> </data>
<data name="job.ruleRun" xml:space="preserve"> <data name="jobs.ruleRun" xml:space="preserve">
<value>Replay Rule.</value> <value>Replay Rule.</value>
</data> </data>
<data name="job.ruleRunNamed" xml:space="preserve"> <data name="jobs.ruleRunNamed" xml:space="preserve">
<value>Replay Rule '{name}'.</value> <value>Replay Rule '{name}'.</value>
</data> </data>
<data name="job.ruleRunNamedSnapshot" xml:space="preserve"> <data name="jobs.ruleRunNamedSnapshot" xml:space="preserve">
<value>Replay Rule '{name}' from states.</value> <value>Replay Rule '{name}' from states.</value>
</data> </data>
<data name="job.ruleRunSnapshot" xml:space="preserve"> <data name="jobs.ruleRunSnapshot" xml:space="preserve">
<value>Replay Rule from states</value> <value>Replay Rule from states</value>
</data> </data>
<data name="jobs.alreadyRunning" xml:space="preserve">
<value>Another job is already running.</value>
</data>
<data name="jobs.invalidTaskName" xml:space="preserve">
<value>Invalid task name</value>
</data>
<data name="jobs.maxReached" xml:space="preserve">
<value>You cannot have more than {max} backups.</value>
</data>
<data name="login.githubPrivateEmail" xml:space="preserve"> <data name="login.githubPrivateEmail" xml:space="preserve">
<value>您的邮箱在 Github 中设置为私有。请设置为公开以使用 Github 登录。</value> <value>您的邮箱在 Github 中设置为私有。请设置为公开以使用 Github 登录。</value>
</data> </data>

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

@ -241,7 +241,7 @@ public sealed class RulesController : ApiController
[ApiCosts(1)] [ApiCosts(1)]
public async Task<IActionResult> PutRuleRun(string app, DomainId id, [FromQuery] bool fromSnapshots = false) public async Task<IActionResult> PutRuleRun(string app, DomainId id, [FromQuery] bool fromSnapshots = false)
{ {
await ruleRunnerService.RunAsync(User.Token()!, App.Id, id, fromSnapshots, HttpContext.RequestAborted); await ruleRunnerService.RunAsync(User.Token()!, App, id, fromSnapshots, HttpContext.RequestAborted);
return NoContent(); return NoContent();
} }

66
backend/tests/Squidex.Domain.Apps.Entities.Tests/Collaboration/CommentCollaborationHandlerTests.cs

@ -136,6 +136,28 @@ public class CommentCollaborationHandlerTests : GivenContext
}, opts => opts.Excluding(x => x.CommentId)); }, opts => opts.Excluding(x => x.CommentId));
} }
[Fact]
public async Task Should_publish_event_for_notification()
{
var text = "My Comment";
var commentsId = DomainId.Create("user42");
var commentItem = new Comment(clock.GetCurrentInstant(), User, text);
var storedEvent = await CreateNotificationAsync(commentsId, commentItem);
storedEvent?.Payload.Should().BeEquivalentTo(
new CommentCreated
{
Actor = User,
AppId = CommentCreated.NoApp,
CommentId = default,
CommentsId = commentsId,
Text = commentItem.Text,
Mentions = null,
}, opts => opts.Excluding(x => x.CommentId));
}
[Fact] [Fact]
public async Task Should_not_enrich_comment_with_mentioned_users_if_users_not_found() public async Task Should_not_enrich_comment_with_mentioned_users_if_users_not_found()
{ {
@ -252,6 +274,50 @@ public class CommentCollaborationHandlerTests : GivenContext
return storedEvent; return storedEvent;
} }
private async Task<Envelope<IEvent>?> CreateNotificationAsync(DomainId commentsId, Comment comment)
{
var document = new Doc();
var docName = sut.UserDocument(commentsId.ToString());
documentManager.Doc = document;
var stream = document.Array("stream");
await sut.OnDocumentLoadedAsync(new DocumentLoadEvent
{
Context = new DocumentContext(docName, 0),
Document = document,
Source = documentManager,
});
var commentJson = TestUtils.DefaultSerializer.Serialize(comment);
Envelope<IEvent>? storedEvent = null;
A.CallTo(() => eventFormatter.ToEventData(A<Envelope<IEvent>>._, A<Guid>._, true))
.Invokes(c =>
{
storedEvent = c.GetArgument<Envelope<IEvent>>(0);
});
await documentManager.UpdateDocAsync(null!, doc =>
{
using (var transaction = doc.WriteTransaction())
{
stream.InsertRange(transaction, 0, InputFactory.FromJson(commentJson));
}
}, default);
await sut.LastTask;
var streamName = $"comments-{DomainId.Combine(CommentCreated.NoApp, commentsId)}";
A.CallTo(() => eventStore.AppendAsync(A<Guid>._, streamName, EtagVersion.Any, A<ICollection<EventData>>._, A<CancellationToken>._))
.MustHaveHappened();
return storedEvent;
}
private void SetupUser(string id, string email) private void SetupUser(string id, string email)
{ {
var user = UserMocks.User(id, email); var user = UserMocks.User(id, email);

2
frontend/src/app/shell/pages/internal/notifications-menu.component.html

@ -1,5 +1,5 @@
<ul class="nav navbar-nav align-items-center flex-nowrap"> <ul class="nav navbar-nav align-items-center flex-nowrap">
<sqx-notifo></sqx-notifo> <sqx-notifo position="bottom-right"></sqx-notifo>
<sqx-notification-dropdown *ngIf="!isNotifoConfigured"></sqx-notification-dropdown> <sqx-notification-dropdown *ngIf="!isNotifoConfigured"></sqx-notification-dropdown>
</ul> </ul>

2
frontend/src/app/shell/pages/internal/notifications-menu.component.ts

@ -28,7 +28,7 @@ export class NotificationsMenuComponent {
constructor(authService: AuthService, uiOptions: UIOptions, constructor(authService: AuthService, uiOptions: UIOptions,
) { ) {
const notifoApiKey = authService.user?.notifoToken; const notifoApiKey = authService.user?.notifoToken;
const notifoApiUrl = uiOptions.value.notifoAPi; const notifoApiUrl = uiOptions.value.notifoApi;
this.isNotifoConfigured = !!notifoApiKey && !!notifoApiUrl; this.isNotifoConfigured = !!notifoApiKey && !!notifoApiUrl;
} }

Loading…
Cancel
Save