Browse Source

Feature/notifo (#529)

* Notifo integration
pull/537/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
35753f08a0
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/DateTimeFluidExtension.cs
  2. 25
      backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsCommandMiddleware.cs
  3. 13
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs
  4. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs
  5. 16
      backend/src/Squidex.Domain.Apps.Entities/History/HistoryService.cs
  6. 26
      backend/src/Squidex.Domain.Apps.Entities/History/NotifoOptions.cs
  7. 279
      backend/src/Squidex.Domain.Apps.Entities/History/NotifoService.cs
  8. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs
  9. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
  10. 2
      backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs
  11. 2
      backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  12. 35
      backend/src/Squidex.Domain.Users/DefaultUserResolver.cs
  13. 12
      backend/src/Squidex.Domain.Users/IUserEventHandler.cs
  14. 2
      backend/src/Squidex.Domain.Users/IUserEvents.cs
  15. 49
      backend/src/Squidex.Domain.Users/UserEvents.cs
  16. 2
      backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs
  17. 4
      backend/src/Squidex.Shared/Users/IUserResolver.cs
  18. 2
      backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsagePerDateDto.cs
  19. 8
      backend/src/Squidex/Areas/Frontend/Middlewares/IndexExtensions.cs
  20. 61
      backend/src/Squidex/Areas/Frontend/Middlewares/NotifoMiddleware.cs
  21. 1
      backend/src/Squidex/Areas/Frontend/Startup.cs
  22. 3
      backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs
  23. 4
      backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs
  24. 24
      backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs
  25. 10
      backend/src/Squidex/Config/Domain/HistoryServices.cs
  26. 2
      backend/src/Squidex/Config/Domain/SubscriptionServices.cs
  27. 2
      backend/src/Squidex/Startup.cs
  28. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/FileTagAssetMetadataSourceTests.cs
  29. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs
  30. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs
  31. 9
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsCommandMiddlewareTests.cs
  32. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveAssetsTests.cs
  33. 64
      backend/tests/Squidex.Domain.Users.Tests/DefaultUserResolverTests.cs
  34. 2
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventConsumersHealthCheckTests.cs
  35. 2
      backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs
  36. 2
      frontend/app/features/content/pages/content/content-history-page.component.ts
  37. 2
      frontend/app/features/content/pages/content/content-page.component.html
  38. 2
      frontend/app/features/content/pages/contents/contents-page.component.html
  39. 2
      frontend/app/features/settings/pages/contributors/contributors-page.component.html
  40. 2
      frontend/app/features/settings/pages/plans/plans-page.component.html
  41. 2
      frontend/app/framework/angular/routers/router-2-state.spec.ts
  42. 2
      frontend/app/framework/angular/routers/router-2-state.ts
  43. 1
      frontend/app/shared/components/notifo.component.html
  44. 26
      frontend/app/shared/components/notifo.component.scss
  45. 98
      frontend/app/shared/components/notifo.component.ts
  46. 1
      frontend/app/shared/declarations.ts
  47. 4
      frontend/app/shared/module.ts
  48. 4
      frontend/app/shared/services/auth.service.ts
  49. 4
      frontend/app/shared/state/apps.state.ts
  50. 21
      frontend/app/shared/state/contents.state.ts
  51. 4
      frontend/app/shared/state/contributors.state.ts
  52. 4
      frontend/app/shared/state/plans.state.ts
  53. 29
      frontend/app/shell/pages/internal/notifications-menu.component.html
  54. 8
      frontend/app/shell/pages/internal/notifications-menu.component.scss
  55. 83
      frontend/app/shell/pages/internal/notifications-menu.component.ts

1
backend/src/Squidex.Domain.Apps.Core.Operations/Templates/Extensions/DateTimeFluidExtension.cs

@ -10,7 +10,6 @@ using Fluid;
using Fluid.Values;
using NodaTime;
using NodaTime.Text;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.Templates.Extensions
{

25
backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsCommandMiddleware.cs

@ -15,8 +15,6 @@ using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Comments
@ -43,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.Comments
{
if (commentsCommand is CreateComment createComment && !IsMention(createComment))
{
await ReplicateCommandAsync(context, createComment);
await MentionUsersAsync(createComment);
}
await ExecuteCommandAsync(context, commentsCommand);
@ -52,27 +50,6 @@ namespace Squidex.Domain.Apps.Entities.Comments
await next(context);
}
private async Task ReplicateCommandAsync(CommandContext context, CommentTextCommand command)
{
await MentionUsersAsync(command);
if (command.Mentions != null)
{
foreach (var userId in command.Mentions)
{
var notificationCommand = SimpleMapper.Map(command, new CreateComment());
notificationCommand.AppId = null!;
notificationCommand.Mentions = null;
notificationCommand.CommentsId = userId;
notificationCommand.ExpectedVersion = EtagVersion.Any;
notificationCommand.IsMention = true;
context.CommandBus.PublishAsync(notificationCommand).Forget();
}
}
}
private async Task ExecuteCommandAsync(CommandContext context, CommentsCommand commentsCommand)
{
var grain = grainFactory.GetGrain<ICommentsGrain>(commentsCommand.CommentsId);

13
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentHistoryEventsCreator.cs

@ -41,15 +41,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
"changed status of {[Schema]} content to {[Status]}.");
AddEventMessage<ContentStatusScheduled>(
"scheduled to change status of {[Schema]} content to {[Status]}.");
"scheduled to change status of {[Schemra]} content to {[Status]}.");
}
protected override Task<HistoryEvent?> CreateEventCoreAsync(Envelope<IEvent> @event)
{
var channel = $"contents.{@event.Headers.AggregateId()}";
var result = ForEvent(@event.Payload, channel);
if (@event.Payload is SchemaEvent schemaEvent)
{
if (schemaEvent.SchemaId == null)
@ -57,7 +55,14 @@ namespace Squidex.Domain.Apps.Entities.Contents
return Task.FromResult<HistoryEvent?>(null);
}
result = result.Param("Schema", schemaEvent.SchemaId.Name);
channel = $"schemas.{schemaEvent.SchemaId.Id}.{channel}";
}
var result = ForEvent(@event.Payload, channel);
if (@event.Payload is SchemaEvent schemaEvent2)
{
result = result.Param("Schema", schemaEvent2.SchemaId.Name);
}
if (@event.Payload is ContentStatusChanged contentStatusChanged)

6
backend/src/Squidex.Domain.Apps.Entities/Contents/Text/TextIndexingProcess.cs

@ -107,7 +107,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
DocId = state.DocIdCurrent,
ServeAll = true,
ServePublished = false,
Texts = data.ToTexts(),
Texts = data.ToTexts()
});
await textIndexerState.SetAsync(state);
@ -245,7 +245,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
},
new DeleteIndexEntry
{
DocId = state.DocIdNew,
DocId = state.DocIdNew
});
state.DocIdNew = null;
@ -267,7 +267,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Text
},
new DeleteIndexEntry
{
DocId = state.DocIdNew ?? NotFound,
DocId = state.DocIdNew ?? NotFound
});
await textIndexerState.RemoveAsync(state.ContentId);

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

@ -21,6 +21,7 @@ namespace Squidex.Domain.Apps.Entities.History
private readonly Dictionary<string, string> texts = new Dictionary<string, string>();
private readonly List<IHistoryEventsCreator> creators;
private readonly IHistoryEventRepository repository;
private readonly NotifoService notifo;
public string Name
{
@ -32,10 +33,11 @@ namespace Squidex.Domain.Apps.Entities.History
get { return ".*"; }
}
public HistoryService(IHistoryEventRepository repository, IEnumerable<IHistoryEventsCreator> creators)
public HistoryService(IHistoryEventRepository repository, IEnumerable<IHistoryEventsCreator> creators, NotifoService notifo)
{
Guard.NotNull(repository, nameof(repository));
Guard.NotNull(creators, nameof(creators));
Guard.NotNull(notifo, nameof(notifo));
this.creators = creators.ToList();
@ -48,6 +50,8 @@ namespace Squidex.Domain.Apps.Entities.History
}
this.repository = repository;
this.notifo = notifo;
}
public bool Handles(StoredEvent @event)
@ -62,16 +66,20 @@ namespace Squidex.Domain.Apps.Entities.History
public async Task On(Envelope<IEvent> @event)
{
await notifo.HandleEventAsync(@event);
foreach (var creator in creators)
{
var historyEvent = await creator.CreateEventAsync(@event);
if (historyEvent != null)
{
var appEvent = (AppEvent)@event.Payload;
var appEvent = @event.To<AppEvent>();
await notifo.HandleHistoryEventAsync(appEvent, historyEvent);
historyEvent.Actor = appEvent.Actor;
historyEvent.AppId = appEvent.AppId.Id;
historyEvent.Actor = appEvent.Payload.Actor;
historyEvent.AppId = appEvent.Payload.AppId.Id;
historyEvent.Created = @event.Headers.Timestamp();
historyEvent.Version = @event.Headers.EventStreamNumber();

26
backend/src/Squidex.Domain.Apps.Entities/History/NotifoOptions.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.History
{
public sealed class NotifoOptions
{
public string AppId { get; set; }
public string ApiKey { get; set; }
public string ApiUrl { get; set; } = "https://app.notifo.io";
public bool IsConfigured()
{
return
!string.IsNullOrWhiteSpace(ApiKey) &&
!string.IsNullOrWhiteSpace(ApiUrl) &&
!string.IsNullOrWhiteSpace(AppId);
}
}
}

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

@ -0,0 +1,279 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using NodaTime;
using Notifo.SDK;
using Notifo.Services;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Domain.Users;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Tasks;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
using static Notifo.Services.Notifications;
namespace Squidex.Domain.Apps.Entities.History
{
public class NotifoService : IInitializable, IUserEventHandler
{
private static readonly Duration MaxAge = Duration.FromHours(12);
private readonly NotifoOptions options;
private readonly IUrlGenerator urlGenerator;
private readonly IUserResolver userResolver;
private readonly IClock clock;
private NotificationsClient? client;
public NotifoService(IOptions<NotifoOptions> options, IUrlGenerator urlGenerator, IUserResolver userResolver, IClock clock)
{
Guard.NotNull(options, nameof(options));
Guard.NotNull(urlGenerator, nameof(urlGenerator));
Guard.NotNull(userResolver, nameof(userResolver));
Guard.NotNull(clock, nameof(clock));
this.options = options.Value;
this.urlGenerator = urlGenerator;
this.userResolver = userResolver;
this.clock = clock;
}
public Task InitializeAsync(CancellationToken ct = default)
{
if (options.IsConfigured())
{
var builder =
NotificationsClientBuilder.Create()
.SetApiKey(options.ApiKey);
if (!string.IsNullOrWhiteSpace(options.ApiUrl))
{
builder = builder.SetApiUrl(options.ApiUrl);
}
client = builder.Build();
}
return Task.CompletedTask;
}
public void OnUserUpdated(IUser user)
{
UpsertUserAsync(user).Forget();
}
private async Task UpsertUserAsync(IUser user)
{
if (client == null)
{
return;
}
var settings = new NotificationSettingsDto();
settings.Channels[Providers.WebPush] = new NotificationSettingDto
{
Send = true,
DelayInSeconds = null
};
settings.Channels[Providers.Email] = new NotificationSettingDto
{
Send = true,
DelayInSeconds = 5 * 60
};
var userRequest = new UpsertUserRequest
{
AppId = options.AppId,
EmailAddress = user.Email,
FullName = user.DisplayName(),
PreferredLanguage = "en",
PreferredTimezone = null,
RequiresWhitelistedTopic = true,
Settings = settings,
UserId = user.Id
};
var response = await client.UpsertUserAsync(userRequest);
await userResolver.SetClaimAsync(user.Id, SquidexClaimTypes.NotifoKey, response.User.ApiKey);
}
public async Task HandleEventAsync(Envelope<IEvent> @event)
{
Guard.NotNull(@event, nameof(@event));
if (client == null)
{
return;
}
switch (@event.Payload)
{
case CommentCreated comment:
{
if (IsTooOld(@event.Headers))
{
return;
}
if (comment.Mentions == null || comment.Mentions.Length == 0)
{
break;
}
using (var stream = client.PublishMany())
{
foreach (var userId in comment.Mentions)
{
var publishRequest = new PublishRequest
{
AppId = options.AppId
};
publishRequest.Topic = $"users/{userId}";
publishRequest.Properties["SquidexApp"] = comment.AppId.Name;
publishRequest.Preformatted = new NotificationFormattingDto();
publishRequest.Preformatted.Subject["en"] = comment.Text;
if (comment.Url?.IsAbsoluteUri == true)
{
publishRequest.Preformatted.LinkUrl["en"] = comment.Url.ToString();
}
SetUser(comment, publishRequest);
await stream.RequestStream.WriteAsync(publishRequest);
}
await stream.RequestStream.CompleteAsync();
await stream.ResponseAsync;
}
break;
}
case AppContributorAssigned contributorAssigned:
{
var user = await userResolver.FindByIdAsync(contributorAssigned.ContributorId);
if (user != null)
{
await UpsertUserAsync(user);
}
var request = BuildAllowedTopicRequest(contributorAssigned, contributorAssigned.ContributorId);
await client.AddAllowedTopicAsync(request);
break;
}
case AppContributorRemoved contributorRemoved:
{
var request = BuildAllowedTopicRequest(contributorRemoved, contributorRemoved.ContributorId);
await client.RemoveAllowedTopicAsync(request);
break;
}
}
}
private AllowedTopicRequest BuildAllowedTopicRequest(AppEvent @event, string contributorId)
{
var topicRequest = new AllowedTopicRequest
{
AppId = options.AppId
};
topicRequest.UserId = contributorId;
topicRequest.TopicPrefix = GetAppPrefix(@event);
return topicRequest;
}
public async Task HandleHistoryEventAsync(Envelope<AppEvent> @event, HistoryEvent historyEvent)
{
if (client == null)
{
return;
}
if (IsTooOld(@event.Headers))
{
return;
}
var appEvent = @event.Payload;
var publishRequest = new PublishRequest
{
AppId = options.AppId
};
foreach (var (key, value) in historyEvent.Parameters)
{
publishRequest.Properties.Add(key, value);
}
publishRequest.Properties["SquidexApp"] = appEvent.AppId.Name;
if (appEvent is ContentEvent c && !(appEvent is ContentDeleted))
{
var url = urlGenerator.ContentUI(c.AppId, c.SchemaId, c.ContentId);
publishRequest.Properties["SquidexUrl"] = url;
}
publishRequest.TemplateCode = historyEvent.EventType;
SetUser(appEvent, publishRequest);
SetTopic(appEvent, publishRequest, historyEvent);
await client.PublishAsync(publishRequest);
}
private bool IsTooOld(EnvelopeHeaders headers)
{
var now = clock.GetCurrentInstant();
return now - headers.Timestamp() > MaxAge;
}
private static void SetUser(AppEvent appEvent, PublishRequest publishRequest)
{
if (appEvent.Actor.IsSubject)
{
publishRequest.CreatorId = appEvent.Actor.Identifier;
}
}
private static void SetTopic(AppEvent appEvent, PublishRequest publishRequest, HistoryEvent @event)
{
var topicPrefix = GetAppPrefix(appEvent);
var topicSuffix = @event.Channel.Replace('.', '/').Trim();
publishRequest.Topic = $"{topicPrefix}/{topicSuffix}";
}
private static string GetAppPrefix(AppEvent appEvent)
{
return $"apps/{appEvent.AppId.Id}";
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleDequeuerGrain.cs

@ -112,7 +112,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
ExecutionResult = response.Status,
Finished = now,
JobNext = jobDelay,
JobResult = ComputeJobResult(response.Status, jobDelay),
JobResult = ComputeJobResult(response.Status, jobDelay)
};
await ruleEventRepository.UpdateAsync(@event.Job, update);

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

@ -50,9 +50,9 @@ namespace Squidex.Domain.Apps.Entities.Rules
this.appProvider = appProvider;
this.cache = cache;
this.localCache = localCache;
this.ruleEventRepository = ruleEventRepository;
this.ruleService = ruleService;
this.localCache = localCache;
}
public bool Handles(StoredEvent @event)

2
backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemaHistoryEventsCreator.cs

@ -77,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas
if (@event.Payload is SchemaEvent schemaEvent)
{
var channel = $"schemas.{schemaEvent.SchemaId.Name}";
var channel = $"schemas.{schemaEvent.SchemaId.Id}";
result = ForEvent(@event.Payload, channel).Param("Name", schemaEvent.SchemaId.Name);

2
backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -12,6 +12,7 @@
<ProjectReference Include="..\Squidex.Domain.Apps.Core.Model\Squidex.Domain.Apps.Core.Model.csproj" />
<ProjectReference Include="..\Squidex.Domain.Apps.Core.Operations\Squidex.Domain.Apps.Core.Operations.csproj" />
<ProjectReference Include="..\Squidex.Domain.Apps.Events\Squidex.Domain.Apps.Events.csproj" />
<ProjectReference Include="..\Squidex.Domain.Users\Squidex.Domain.Users.csproj" />
<ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
<ProjectReference Include="..\Squidex.Shared\Squidex.Shared.csproj" />
</ItemGroup>
@ -35,6 +36,7 @@
</PackageReference>
<PackageReference Include="Microsoft.Orleans.Core" Version="3.2.0" />
<PackageReference Include="NodaTime" Version="3.0.0" />
<PackageReference Include="Notifo.SDK" Version="1.0.0-alpha8" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="1.7.1" />

35
backend/src/Squidex.Domain.Users/DefaultUserResolver.cs

@ -8,6 +8,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
@ -65,6 +66,28 @@ namespace Squidex.Domain.Users
}
}
public async Task SetClaimAsync(string id, string type, string value)
{
Guard.NotNullOrEmpty(id, nameof(id));
Guard.NotNullOrEmpty(type, nameof(type));
Guard.NotNullOrEmpty(value, nameof(value));
using (var scope = serviceProvider.CreateScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
var values = new UserValues
{
CustomClaims = new List<Claim>
{
new Claim(type, value)
}
};
await userManager.UpdateAsync(id, values);
}
}
public async Task<IUser?> FindByIdAsync(string id)
{
Guard.NotNullOrEmpty(id, nameof(id));
@ -98,6 +121,18 @@ namespace Squidex.Domain.Users
}
}
public async Task<List<IUser>> QueryAllAsync()
{
using (var scope = serviceProvider.CreateScope())
{
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
var result = await userManager.QueryByEmailAsync(null);
return result.OfType<IUser>().ToList();
}
}
public async Task<List<IUser>> QueryByEmailAsync(string email)
{
Guard.NotNullOrEmpty(email, nameof(email));

12
backend/src/Squidex.Domain.Users/NoopUserEvents.cs → backend/src/Squidex.Domain.Users/IUserEventHandler.cs

@ -1,7 +1,7 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
@ -9,13 +9,17 @@ using Squidex.Shared.Users;
namespace Squidex.Domain.Users
{
public sealed class NoopUserEvents : IUserEvents
public interface IUserEventHandler
{
public void OnConsentGiven(IUser user)
void OnUserRegistered(IUser user)
{
}
public void OnUserRegistered(IUser user)
void OnUserUpdated(IUser user)
{
}
void OnConsentGiven(IUser user)
{
}
}

2
backend/src/Squidex.Domain.Users/IUserEvents.cs

@ -13,6 +13,8 @@ namespace Squidex.Domain.Users
{
void OnUserRegistered(IUser user);
void OnUserUpdated(IUser user);
void OnConsentGiven(IUser user);
}
}

49
backend/src/Squidex.Domain.Users/UserEvents.cs

@ -0,0 +1,49 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using Squidex.Infrastructure;
using Squidex.Shared.Users;
namespace Squidex.Domain.Users
{
public sealed class UserEvents : IUserEvents
{
private readonly IEnumerable<IUserEventHandler> userEventHandlers;
public UserEvents(IEnumerable<IUserEventHandler> userEventHandlers)
{
Guard.NotNull(userEventHandlers, nameof(userEventHandlers));
this.userEventHandlers = userEventHandlers;
}
public void OnUserRegistered(IUser user)
{
foreach (var handler in userEventHandlers)
{
handler.OnUserRegistered(user);
}
}
public void OnUserUpdated(IUser user)
{
foreach (var handler in userEventHandlers)
{
handler.OnUserUpdated(user);
}
}
public void OnConsentGiven(IUser user)
{
foreach (var handler in userEventHandlers)
{
handler.OnConsentGiven(user);
}
}
}
}

2
backend/src/Squidex.Shared/Identity/SquidexClaimTypes.cs

@ -13,6 +13,8 @@ namespace Squidex.Shared.Identity
public static readonly string PictureUrl = "urn:squidex:picture";
public static readonly string NotifoKey = "urn:squidex:notifo";
public static readonly string Consent = "urn:squidex:consent";
public static readonly string ConsentForEmails = "urn:squidex:consent:emails";

4
backend/src/Squidex.Shared/Users/IUserResolver.cs

@ -18,8 +18,12 @@ namespace Squidex.Shared.Users
Task<IUser?> FindByIdAsync(string idOrEmail);
Task SetClaimAsync(string id, string type, string value);
Task<List<IUser>> QueryByEmailAsync(string email);
Task<List<IUser>> QueryAllAsync();
Task<Dictionary<string, IUser>> QueryManyAsync(string[] ids);
}
}

2
backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsagePerDateDto.cs

@ -42,7 +42,7 @@ namespace Squidex.Areas.Api.Controllers.Statistics.Models
Date = DateTime.SpecifyKind(stats.Date, DateTimeKind.Utc),
TotalBytes = stats.TotalBytes,
TotalCalls = stats.TotalCalls,
AverageElapsedMs = stats.AverageElapsedMs,
AverageElapsedMs = stats.AverageElapsedMs
};
return result;

8
backend/src/Squidex/Areas/Frontend/Middlewares/IndexExtensions.cs

@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Squidex.Areas.Api.Controllers.UI;
using Squidex.Domain.Apps.Entities.History;
using Squidex.Infrastructure.Json;
using Squidex.Web;
@ -52,6 +53,13 @@ namespace Squidex.Areas.Frontend.Middlewares
uiOptions.More["info"] = values.ToString();
}
var notifo = httpContext.RequestServices.GetService<IOptions<NotifoOptions>>();
if (notifo.Value.IsConfigured())
{
uiOptions.More["notifoApi"] = notifo.Value.ApiUrl;
}
var jsonSerializer = httpContext.RequestServices.GetRequiredService<IJsonSerializer>();
var jsonOptions = jsonSerializer.Serialize(uiOptions);

61
backend/src/Squidex/Areas/Frontend/Middlewares/NotifoMiddleware.cs

@ -0,0 +1,61 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using Squidex.Domain.Apps.Entities.History;
namespace Squidex.Areas.Frontend.Middlewares
{
public class NotifoMiddleware
{
private readonly RequestDelegate next;
private readonly string? workerUrl;
public NotifoMiddleware(RequestDelegate next, IOptions<NotifoOptions> options)
{
this.next = next;
workerUrl = GetUrl(options.Value);
}
public async Task InvokeAsync(HttpContext context)
{
if (context.Request.Path.Equals("/notifo-sw.js") && workerUrl != null)
{
context.Response.Headers[HeaderNames.ContentType] = "text/javascript";
var script = $"importScripts('{workerUrl}')";
await context.Response.WriteAsync(script);
}
else
{
await next(context);
}
}
private static string? GetUrl(NotifoOptions options)
{
if (!options.IsConfigured())
{
return null;
}
if (options.ApiUrl.Contains("localhost:5002"))
{
return "https://localhost:3002/notifo-sdk-worker.js";
}
else
{
return $"{options.ApiUrl}/build/notifo-sdk-worker.js";
}
}
}
}

1
backend/src/Squidex/Areas/Frontend/Startup.cs

@ -25,6 +25,7 @@ namespace Squidex.Areas.Frontend
var environment = app.ApplicationServices.GetRequiredService<IWebHostEnvironment>();
app.UseMiddleware<SquidMiddleware>();
app.UseMiddleware<NotifoMiddleware>();
app.Use((context, next) =>
{

3
backend/src/Squidex/Areas/IdentityServer/Config/IdentityServerServices.cs

@ -96,7 +96,8 @@ namespace Squidex.Areas.IdentityServer.Config
new[]
{
SquidexClaimTypes.DisplayName,
SquidexClaimTypes.PictureUrl
SquidexClaimTypes.PictureUrl,
SquidexClaimTypes.NotifoKey
});
}
}

4
backend/src/Squidex/Areas/IdentityServer/Controllers/Account/AccountController.cs

@ -360,7 +360,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account
return MakeIdentityOperation(() => userManager.SetLockoutEndDateAsync(user.Identity, DateTimeOffset.UtcNow.AddYears(100)));
}
private Task<bool> AddClaimsAsync(UserWithClaims user, ExternalLoginInfo externalLogin, string email, bool isFirst = false)
private async Task<bool> AddClaimsAsync(UserWithClaims user, ExternalLoginInfo externalLogin, string email, bool isFirst = false)
{
var update = new UserValues
{
@ -382,7 +382,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Account
update.Permissions = new PermissionSet(Permissions.Admin);
}
return MakeIdentityOperation(() => userManager.SyncClaims(user.Identity, update));
return await MakeIdentityOperation(() => userManager.SyncClaims(user.Identity, update));
}
private IActionResult RedirectToLogoutUrl(LogoutRequest context)

24
backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs

@ -33,6 +33,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
private readonly SignInManager<IdentityUser> signInManager;
private readonly UserManager<IdentityUser> userManager;
private readonly IUserPictureStore userPictureStore;
private readonly IUserEvents userEvents;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
private readonly MyIdentityOptions identityOptions;
@ -40,6 +41,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager,
IUserPictureStore userPictureStore,
IUserEvents userEvents,
IAssetThumbnailGenerator assetThumbnailGenerator,
IOptions<MyIdentityOptions> identityOptions)
{
@ -47,6 +49,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
this.identityOptions = identityOptions.Value;
this.userManager = userManager;
this.userPictureStore = userPictureStore;
this.userEvents = userEvents;
this.assetThumbnailGenerator = assetThumbnailGenerator;
}
@ -84,7 +87,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
[Route("/account/profile/update/")]
public Task<IActionResult> UpdateProfile(ChangeProfileModel model)
{
return MakeChangeAsync(u => userManager.UpdateSafeAsync(u, model.ToValues()),
return MakeChangeAsync(u => UpdateAsync(u, model.ToValues()),
"Account updated successfully.", model);
}
@ -92,7 +95,7 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
[Route("/account/profile/properties/")]
public Task<IActionResult> UpdateProperties(ChangePropertiesModel model)
{
return MakeChangeAsync(u => userManager.UpdateSafeAsync(u, model.ToValues()),
return MakeChangeAsync(u => UpdateAsync(u, model.ToValues()),
"Account updated successfully.", model);
}
@ -143,6 +146,23 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile
return await userManager.AddLoginAsync(user, externalLogin);
}
private async Task<IdentityResult> UpdateAsync(IdentityUser user, UserValues values)
{
var result = await userManager.UpdateSafeAsync(user, values);
if (result.Succeeded)
{
var resolved = await userManager.ResolveUserAsync(user);
if (resolved != null)
{
userEvents.OnUserUpdated(resolved);
}
}
return result;
}
private async Task<IdentityResult> UpdatePictureAsync(List<IFormFile> file, IdentityUser user)
{
if (file.Count != 1)

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

@ -5,16 +5,24 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Squidex.Domain.Apps.Entities.History;
using Squidex.Domain.Users;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Config.Domain
{
public static class HistoryServices
{
public static void AddSquidexHistory(this IServiceCollection services)
public static void AddSquidexHistory(this IServiceCollection services, IConfiguration config)
{
services.Configure<NotifoOptions>(
config.GetSection("notifo"));
services.AddSingletonAs<NotifoService>()
.AsSelf().As<IUserEventHandler>();
services.AddSingletonAs<HistoryService>()
.As<IEventConsumer>().As<IHistoryService>();
}

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

@ -30,7 +30,7 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<UsageGate>()
.AsSelf();
services.AddSingletonAs<NoopUserEvents>()
services.AddSingletonAs<UserEvents>()
.AsOptional<IUserEvents>();
}
}

2
backend/src/Squidex/Startup.cs

@ -54,7 +54,7 @@ namespace Squidex
services.AddSquidexEventPublisher(config);
services.AddSquidexEventSourcing(config);
services.AddSquidexHealthChecks(config);
services.AddSquidexHistory();
services.AddSquidexHistory(config);
services.AddSquidexIdentity(config);
services.AddSquidexIdentityServer();
services.AddSquidexInfrastructure(config);

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/FileTagAssetMetadataSourceTests.cs

@ -84,7 +84,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
["videoWidth"] = JsonValue.Create(128),
["videoHeight"] = JsonValue.Create(55),
["duration"] = JsonValue.Create("00:10:12"),
["duration"] = JsonValue.Create("00:10:12")
},
Type = AssetType.Video
};
@ -101,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
Metadata = new AssetMetadata
{
["duration"] = JsonValue.Create("00:10:12"),
["duration"] = JsonValue.Create("00:10:12")
},
Type = AssetType.Audio
};

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs

@ -103,7 +103,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
Metadata = new AssetMetadata()
{
["pixelWidth"] = JsonValue.Create(128),
["pixelHeight"] = JsonValue.Create(55),
["pixelHeight"] = JsonValue.Create(55)
},
Type = AssetType.Image
};

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryFixture.cs

@ -99,7 +99,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.MongoDb
{
["value"] = JsonValue.Create(tag)
},
Slug = fileName,
Slug = fileName
};
await ExecuteBatchAsync(asset);

9
backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsCommandMiddlewareTests.cs

@ -87,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.Comments
}
[Fact]
public async Task Should_invoke_commands_for_mentioned_users()
public async Task Should_not_invoke_commands_for_mentioned_users()
{
SetupUser("id1", "mail1@squidex.io");
SetupUser("id2", "mail2@squidex.io");
@ -101,11 +101,8 @@ namespace Squidex.Domain.Apps.Entities.Comments
await sut.HandleAsync(context);
A.CallTo(() => commandBus.PublishAsync(A<ICommand>.That.Matches(x => IsForUser(x, "id1"))))
.MustHaveHappened();
A.CallTo(() => commandBus.PublishAsync(A<ICommand>.That.Matches(x => IsForUser(x, "id2"))))
.MustHaveHappened();
A.CallTo(() => commandBus.PublishAsync(A<ICommand>._))
.MustNotHaveHappened();
}
[Fact]

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveAssetsTests.cs

@ -86,7 +86,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
new[] { document1.Id }),
CreateContent(
new[] { document2.Id },
new[] { document2.Id }),
new[] { document2.Id })
};
A.CallTo(() => assetQuery.QueryAsync(A<Context>.That.Matches(x => !x.ShouldEnrichAsset()), null, A<Q>.That.Matches(x => x.Ids.Count == 2)))

64
backend/tests/Squidex.Domain.Users.Tests/DefaultUserResolverTests.cs

@ -118,16 +118,55 @@ namespace Squidex.Domain.Users
Assert.False(created);
}
[Fact]
public async Task Should_add_claim_when_not_added_yet()
{
var (user, claims) = GenerateUser("id2");
A.CallTo(() => userManager.AddClaimsAsync(user, A<IEnumerable<Claim>>._))
.Returns(IdentityResult.Success);
SetupUser(user, claims);
await sut.SetClaimAsync("id2", "my-claim", "new-value");
A.CallTo(() => userManager.AddClaimsAsync(user,
A<IEnumerable<Claim>>.That.Matches(x => x.Any(y => y.Type == "my-claim" && y.Value == "new-value"))))
.MustHaveHappened();
}
[Fact]
public async Task Should_remove_previous_claim()
{
var (user, claims) = GenerateUser("id2");
claims.Add(new Claim("my-claim", "old-value"));
A.CallTo(() => userManager.AddClaimsAsync(user, A<IEnumerable<Claim>>._))
.Returns(IdentityResult.Success);
A.CallTo(() => userManager.RemoveClaimsAsync(user, A<IEnumerable<Claim>>._))
.Returns(IdentityResult.Success);
SetupUser(user, claims);
await sut.SetClaimAsync("id2", "my-claim", "new-value");
A.CallTo(() => userManager.AddClaimsAsync(user,
A<IEnumerable<Claim>>.That.Matches(x => x.Any(y => y.Type == "my-claim" && y.Value == "new-value"))))
.MustHaveHappened();
A.CallTo(() => userManager.RemoveClaimsAsync(user,
A<IEnumerable<Claim>>.That.Matches(x => x.Any(y => y.Type == "my-claim" && y.Value == "old-value"))))
.MustHaveHappened();
}
[Fact]
public async Task Should_resolve_user_by_email()
{
var (user, claims) = GenerateUser("id1");
A.CallTo(() => userManager.FindByEmailAsync(user.Email))
.Returns(user);
A.CallTo(() => userManager.GetClaimsAsync(user))
.Returns(claims);
SetupUser(user, claims);
var result = await sut.FindByIdOrEmailAsync(user.Email);
@ -142,11 +181,7 @@ namespace Squidex.Domain.Users
{
var (user, claims) = GenerateUser("id2");
A.CallTo(() => userManager.FindByIdAsync(user.Id))
.Returns(user);
A.CallTo(() => userManager.GetClaimsAsync(user))
.Returns(claims);
SetupUser(user, claims);
var result = await sut.FindByIdOrEmailAsync(user.Id)!;
@ -161,11 +196,7 @@ namespace Squidex.Domain.Users
{
var (user, claims) = GenerateUser("id2");
A.CallTo(() => userManager.FindByIdAsync(user.Id))
.Returns(user);
A.CallTo(() => userManager.GetClaimsAsync(user))
.Returns(claims);
SetupUser(user, claims);
var result = await sut.FindByIdAsync(user.Id)!;
@ -218,6 +249,9 @@ namespace Squidex.Domain.Users
A.CallTo(() => userManager.FindByEmailAsync(user.Email))
.Returns(user);
A.CallTo(() => userManager.FindByIdAsync(user.Id))
.Returns(user);
A.CallTo(() => userManager.GetClaimsAsync(user))
.Returns(claims);
}

2
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventConsumersHealthCheckTests.cs

@ -48,7 +48,7 @@ namespace Squidex.Infrastructure.EventSourcing
{
consumers.Add(new EventConsumerInfo
{
Name = "Consumer1",
Name = "Consumer1"
});
consumers.Add(new EventConsumerInfo

2
backend/tests/Squidex.Infrastructure.Tests/UsageTracking/BackgroundUsageTrackerTests.cs

@ -125,7 +125,7 @@ namespace Squidex.Infrastructure.UsageTracking
(dateFrom.AddDays(1), new Counters()),
(dateFrom.AddDays(2), new Counters()),
(dateFrom.AddDays(3), new Counters()),
(dateFrom.AddDays(4), new Counters()),
(dateFrom.AddDays(4), new Counters())
}
};
}

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

@ -64,7 +64,7 @@ export class ContentHistoryPageComponent extends ResourceOwner implements OnInit
this.contentEvents =
this.contentsState.selectedContent.pipe(
filter(x => !!x),
map(content => `contents.${content?.id}`),
map(content => `schemas.${this.schemasState.schemaId}.contents.${content?.id}`),
switchSafe(channel => timer(0, 5000).pipe(map(() => channel))),
switchSafe(channel => this.historyService.getHistory(this.appsState.appName, channel)));
}

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

@ -22,6 +22,8 @@
<ng-container menu>
<ng-container *ngIf="content; else noContent">
<ng-container>
<sqx-notifo topic="apps/{{contentsState.appId}}/schemas/{{contentsState.schemaId}}/contents/{{content.id}}"></sqx-notifo>
<sqx-preview-button [schema]="schema" [content]="content"></sqx-preview-button>
<div class="dropdown dropdown-options ml-1" *ngIf="content?.canDelete">

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

@ -8,6 +8,8 @@
<ng-container menu>
<div class="row no-gutters pl-1">
<div class="col-auto ml-8">
<sqx-notifo topic="apps/{{contentsState.appId}}/schemas/{{contentsState.schemaId}}/contents"></sqx-notifo>
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut>
<button type="button" class="btn btn-text-secondary" (click)="reload()" title="Refresh Contents (CTRL + SHIFT + R)">

2
frontend/app/features/settings/pages/contributors/contributors-page.component.html

@ -6,6 +6,8 @@
</ng-container>
<ng-container menu>
<sqx-notifo topic="apps/{{contributorsState.appId}}/settings/contributors"></sqx-notifo>
<button type="button" class="btn btn-text-secondary mr-2" (click)="reload()" title="Refresh contributors (CTRL + SHIFT + R)">
<i class="icon-reset"></i> Refresh
</button>

2
frontend/app/features/settings/pages/plans/plans-page.component.html

@ -6,6 +6,8 @@
</ng-container>
<ng-container menu>
<sqx-notifo topic="apps/{{plansState.appId}}/settings/plan"></sqx-notifo>
<button type="button" class="btn btn-text-secondary" (click)="reload()" title="Refresh Plans (CTRL + SHIFT + R)">
<i class="icon-reset"></i> Refresh
</button>

2
frontend/app/framework/angular/routers/router-2-state.spec.ts

@ -6,7 +6,7 @@
*/
import { NavigationExtras, Params, Router } from '@angular/router';
import { LocalStoreService, MathHelper, Pager } from '@app/shared';
import { LocalStoreService, MathHelper, Pager } from '@app/framework/internal';
import { BehaviorSubject } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq';
import { State } from './../../state';

2
frontend/app/framework/angular/routers/router-2-state.ts

@ -153,7 +153,7 @@ export class Router2State implements OnDestroy, StateSynchronizer {
this.mapper?.ngOnDestroy();
}
public mapTo<T extends object>(state: State<T>): Router2StateMap<T> {
public mapTo<T extends object>(state: State<T>) {
this.mapper?.ngOnDestroy();
this.mapper = this.mapper || new Router2StateMap<T>(state, this.route, this.router, this.localStore);

1
frontend/app/shared/components/notifo.component.html

@ -0,0 +1 @@
<span #element></span>

26
frontend/app/shared/components/notifo.component.scss

@ -0,0 +1,26 @@
:host ::ng-deep {
.notifo-notifications-button {
margin-left: .75rem;
margin-right: .75rem;
margin-top: .25rem;
svg {
fill: $color-white;
}
}
.notifo-topics-button {
margin-left: .75rem;
margin-right: .75rem;
svg {
fill: darken($color-border, 30%);
}
}
.notifo-container {
display: inline-block;
max-width: 5rem;
min-width: 3rem;
}
}

98
frontend/app/shared/components/notifo.component.ts

@ -0,0 +1,98 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, Renderer2, SimpleChanges, ViewChild } from '@angular/core';
import { Pager, ResourceLoaderService, UIOptions } from '@app/framework';
import { AuthService } from '@app/shared/internal';
@Component({
selector: 'sqx-notifo',
styleUrls: ['./notifo.component.scss'],
templateUrl: './notifo.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NotifoComponent implements AfterViewInit, OnChanges, OnDestroy {
private readonly notifoApiUrl: string;
private readonly notifoApiKey: string | undefined;
@Output()
public pagerChange = new EventEmitter<Pager>();
@Input()
public topic: string;
@ViewChild('element', { static: false })
public element: ElementRef<Element>;
constructor(resourceLoader: ResourceLoaderService, uiOptions: UIOptions, authService: AuthService,
private readonly renderer: Renderer2
) {
this.notifoApiKey = authService.user?.notifoToken;
this.notifoApiUrl = uiOptions.get('more.notifoApi');
if (this.notifoApiKey) {
if (this.notifoApiUrl.indexOf('localhost:5002') >= 0) {
resourceLoader.loadScript(`https://localhost:3002/notifo-sdk.js`);
} else {
resourceLoader.loadScript(`${this.notifoApiUrl}/build/notifo-sdk.js`);
}
}
}
public ngAfterViewInit() {
if (this.notifoApiKey) {
let notifo = window['notifo'];
if (!notifo) {
notifo = [];
if (this.notifoApiUrl.indexOf('localhost:5002') >= 0) {
notifo.push(['set', 'style', 'https://localhost:3002/notifo-sdk.css']);
}
notifo.push(['set', 'api-url', this.notifoApiUrl]);
notifo.push(['set', 'user-token', this.notifoApiKey]);
notifo.push(['subscribe']);
window['notifo'] = notifo;
}
const element = this.element?.nativeElement;
if (!this.topic) {
notifo.push(['show-notifications', element, { position: 'bottom-right' }]);
} else {
notifo.push(['show-topic', element, this.topic, { style: 'bell' }]);
}
if (element) {
this.renderer.addClass(element, 'notifo-container');
}
}
}
public ngOnChanges(changes: SimpleChanges) {
const notifo = window['notifo'];
const element = this.element?.nativeElement;
if (notifo && changes['topic'] && element) {
notifo.push(['hide-topic', element]);
notifo.push(['show-topic', element, this.topic, { style: 'bell' }]);
}
}
public ngOnDestroy() {
const notifo = window['notifo'];
const element = this.element?.nativeElement;
if (notifo && this.topic && element) {
notifo.push(['hide-topic', element]);
}
}
}

1
frontend/app/shared/declarations.ts

@ -32,6 +32,7 @@ export * from './components/help/help.component';
export * from './components/history/history-list.component';
export * from './components/history/history.component';
export * from './components/history/pipes';
export * from './components/notifo.component';
export * from './components/pipes';
export * from './components/schema-category.component';
export * from './components/search/queries/filter-comparison.component';

4
frontend/app/shared/module.ts

@ -13,7 +13,7 @@ import { ModuleWithProviders, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { SqxFrameworkModule } from '@app/framework';
import { MentionModule } from 'angular-mentions';
import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetsDialogState, AssetsListComponent, AssetsSelectorComponent, AssetsService, AssetsState, AssetUploaderComponent, AssetUploaderState, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentMustExistGuard, ContentsService, ContentsState, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, GeolocationEditorComponent, GraphQlService, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, ImageCropperComponent, ImageFocusPointComponent, LanguageSelectorComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, PatternsService, PatternsState, PlansService, PlansState, QueryComponent, QueryListComponent, QueryPathComponent, ReferencesCheckboxesComponent, ReferencesDropdownComponent, ReferencesTagsComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, SearchFormComponent, SortingComponent, StockPhotoService, TableHeaderComponent, TranslationsService, UIService, UIState, UnsetAppGuard, UnsetContentGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WorkflowsService, WorkflowsState } from './declarations';
import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetsDialogState, AssetsListComponent, AssetsSelectorComponent, AssetsService, AssetsState, AssetUploaderComponent, AssetUploaderState, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentMustExistGuard, ContentsService, ContentsState, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, GeolocationEditorComponent, GraphQlService, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, ImageCropperComponent, ImageFocusPointComponent, LanguageSelectorComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, NotifoComponent, PatternsService, PatternsState, PlansService, PlansState, QueryComponent, QueryListComponent, QueryPathComponent, ReferencesCheckboxesComponent, ReferencesDropdownComponent, ReferencesTagsComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, SearchFormComponent, SortingComponent, StockPhotoService, TableHeaderComponent, TranslationsService, UIService, UIState, UnsetAppGuard, UnsetContentGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WorkflowsService, WorkflowsState } from './declarations';
import { SearchService } from './services/search.service';
@NgModule({
@ -52,6 +52,7 @@ import { SearchService } from './services/search.service';
ImageFocusPointComponent,
LanguageSelectorComponent,
MarkdownEditorComponent,
NotifoComponent,
QueryComponent,
QueryListComponent,
QueryPathComponent,
@ -95,6 +96,7 @@ import { SearchService } from './services/search.service';
HistoryMessagePipe,
LanguageSelectorComponent,
MarkdownEditorComponent,
NotifoComponent,
QueryListComponent,
ReferencesCheckboxesComponent,
ReferencesDropdownComponent,

4
frontend/app/shared/services/auth.service.ts

@ -28,6 +28,10 @@ export class Profile {
return this.user.profile['urn:squidex:picture'];
}
public get notifoToken(): string | undefined {
return this.user.profile['urn:squidex:notifo'];
}
public get isExpired(): boolean {
return this.user.expired || false;
}

4
frontend/app/shared/state/apps.state.ts

@ -34,6 +34,10 @@ export class AppsState extends State<Snapshot> {
return this.snapshot.selectedApp?.name || '';
}
public get appId() {
return this.snapshot.selectedApp?.id || '';
}
public get appDisplayName() {
return this.snapshot.selectedApp?.displayName || '';
}

21
frontend/app/shared/state/contents.state.ts

@ -79,6 +79,14 @@ export abstract class ContentsStateBase extends State<Snapshot> {
public statusQueries =
this.projectFrom(this.statuses, x => buildStatusQueries(x));
public get appName() {
return this.appsState.appName;
}
public get appId() {
return this.appsState.appId;
}
constructor(
private readonly appsState: AppsState,
private readonly contentsService: ContentsService,
@ -300,9 +308,6 @@ export abstract class ContentsStateBase extends State<Snapshot> {
return this.loadInternal(false);
}
private get appName() {
return this.appsState.appName;
}
private replaceContent(content: ContentDto, oldVersion?: Version, updateText?: string) {
if (!oldVersion || !oldVersion.eq(content.version)) {
@ -324,7 +329,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
}
}
protected abstract get schemaId(): string;
public abstract get schemaId(): string;
}
@Injectable()
@ -335,8 +340,8 @@ export class ContentsState extends ContentsStateBase {
super(appsState, contentsService, dialogs);
}
protected get schemaId() {
return this.schemasState.schemaName;
public get schemaId() {
return this.schemasState.schemaId;
}
}
@ -350,8 +355,8 @@ export class ManualContentsState extends ContentsStateBase {
super(appsState, contentsService, dialogs);
}
protected get schemaId() {
return this.schema.name;
public get schemaId() {
return this.schema.id;
}
}

4
frontend/app/shared/state/contributors.state.ts

@ -72,6 +72,10 @@ export class ContributorsState extends State<Snapshot> {
public contributorsPaged =
this.projectFrom2(this.contributorsPager, this.filtered, (p, c) => getPagedContributors(c, p));
public get appId() {
return this.appsState.appId;
}
constructor(
private readonly appsState: AppsState,
private readonly contributorsService: ContributorsService,

4
frontend/app/shared/state/plans.state.ts

@ -66,6 +66,10 @@ export class PlansState extends State<Snapshot> {
public window = window;
public get appId() {
return this.appsState.appId;
}
constructor(
private readonly appsState: AppsState,
private readonly authState: AuthService,

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

@ -1,28 +1,3 @@
<ul class="nav navbar-nav">
<li class="nav-item nav-icon dropdown" #button>
<span class="nav-link dropdown-toggle" (click)="modalMenu.show()">
<i class="icon-comments"></i>
<span class="badge badge-pill" *ngIf="unread">{{unread}}</span>
</span>
</li>
</ul>
<ng-container *sqxModal="modalMenu;onRoot:false">
<div class="dropdown-menu" [scrollTop]="scrollMe.scrollHeight" [sqxAnchoredTo]="button" [offset]="10" @fade #scrollMe>
<ng-container *ngIf="commentsState.comments | async; let comments">
<small class="text-muted" *ngIf="comments.length === 0">
No notifications yet.
</small>
<sqx-comment *ngFor="let comment of comments; trackBy: trackByComment"
[comment]="comment"
[commentsState]="commentsState"
[confirmDelete]="false"
[canDelete]="true"
[canFollow]="true"
[userToken]="userToken">
</sqx-comment>
</ng-container>
</div>
</ng-container>
<sqx-notifo></sqx-notifo>
</ul>

8
frontend/app/shell/pages/internal/notifications-menu.component.scss

@ -1,8 +0,0 @@
.dropdown-menu {
max-height: 500px;
min-height: 5rem;
overflow-y: scroll;
padding: 1.5rem;
padding-bottom: 1rem;
width: 300px;
}

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

@ -5,92 +5,13 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { AuthService, CommentDto, CommentsService, CommentsState, DialogService, fadeAnimation, LocalStoreService, ModalModel, ResourceOwner } from '@app/shared';
import { timer } from 'rxjs';
import { onErrorResumeNext, switchMap, tap } from 'rxjs/operators';
const CONFIG_KEY = 'notifications.version';
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
selector: 'sqx-notifications-menu',
styleUrls: ['./notifications-menu.component.scss'],
templateUrl: './notifications-menu.component.html',
animations: [
fadeAnimation
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NotificationsMenuComponent extends ResourceOwner implements OnInit {
private isOpen: boolean;
public modalMenu = new ModalModel();
public commentsState: CommentsState;
public versionRead = -1;
public versionReceived = -1;
public userToken: string;
public get unread() {
return Math.max(0, this.versionReceived - this.versionRead);
}
constructor(authService: AuthService, commentsService: CommentsService, dialogs: DialogService,
private readonly changeDetector: ChangeDetectorRef,
private readonly localStore: LocalStoreService
) {
super();
this.userToken = authService.user!.token;
this.versionRead = localStore.getInt(CONFIG_KEY, -1);
this.versionReceived = this.versionRead;
const commentsUrl = `users/${authService.user!.id}/notifications`;
this.commentsState =
new CommentsState(
commentsUrl,
commentsService,
dialogs,
true,
this.versionRead);
}
public ngOnInit() {
this.own(
this.modalMenu.isOpen.pipe(
tap(isOpen => {
this.isOpen = isOpen;
this.updateVersion();
})
));
this.own(
this.commentsState.versionNumber.pipe(
tap(version => {
this.versionReceived = version;
this.updateVersion();
this.changeDetector.detectChanges();
})));
this.own(timer(0, 4000).pipe(switchMap(() => this.commentsState.load(true).pipe(onErrorResumeNext()))));
}
public trackByComment(comment: CommentDto) {
return comment.id;
}
private updateVersion() {
if (this.isOpen) {
this.versionRead = this.versionReceived;
this.localStore.setInt(CONFIG_KEY, this.versionRead);
}
}
export class NotificationsMenuComponent {
}
Loading…
Cancel
Save