mirror of https://github.com/Squidex/squidex.git
Browse Source
* More the API more idempotent * Mention users. * Temp * Rebuild comments grain to use string key. * Improvements for event store. * Temporary. * Notifications. * Modal fixes. * Build fix. * Another build fix. * Frontend errors fixed.pull/464/head
committed by
GitHub
76 changed files with 1353 additions and 351 deletions
@ -0,0 +1,113 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Text.RegularExpressions; |
|||
using System.Threading.Tasks; |
|||
using Orleans; |
|||
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 |
|||
{ |
|||
public sealed class CommentsCommandMiddleware : ICommandMiddleware |
|||
{ |
|||
private static readonly Regex MentionRegex = new Regex(@"@(?=.{1,254}$)(?=.{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, TimeSpan.FromMilliseconds(100)); |
|||
private readonly IGrainFactory grainFactory; |
|||
private readonly IUserResolver userResolver; |
|||
|
|||
public CommentsCommandMiddleware(IGrainFactory grainFactory, IUserResolver userResolver) |
|||
{ |
|||
Guard.NotNull(grainFactory); |
|||
Guard.NotNull(userResolver); |
|||
|
|||
this.grainFactory = grainFactory; |
|||
|
|||
this.userResolver = userResolver; |
|||
} |
|||
|
|||
public async Task HandleAsync(CommandContext context, Func<Task> next) |
|||
{ |
|||
if (context.Command is CommentsCommand commentsCommand) |
|||
{ |
|||
if (commentsCommand is CreateComment createComment && !IsMention(createComment)) |
|||
{ |
|||
await MentionUsersAsync(createComment); |
|||
|
|||
if (createComment.Mentions != null) |
|||
{ |
|||
foreach (var userId in createComment.Mentions) |
|||
{ |
|||
var notificationCommand = SimpleMapper.Map(createComment, new CreateComment()); |
|||
|
|||
notificationCommand.AppId = null!; |
|||
notificationCommand.Mentions = null; |
|||
notificationCommand.CommentsId = userId; |
|||
notificationCommand.ExpectedVersion = EtagVersion.Any; |
|||
notificationCommand.IsMention = true; |
|||
|
|||
context.CommandBus.PublishAsync(notificationCommand).Forget(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
await ExecuteCommandAsync(context, commentsCommand); |
|||
} |
|||
|
|||
await next(); |
|||
} |
|||
|
|||
private async Task ExecuteCommandAsync(CommandContext context, CommentsCommand commentsCommand) |
|||
{ |
|||
var grain = grainFactory.GetGrain<ICommentsGrain>(commentsCommand.CommentsId); |
|||
|
|||
var result = await grain.ExecuteAsync(commentsCommand.AsJ()); |
|||
|
|||
context.Complete(result.Value); |
|||
} |
|||
|
|||
private static bool IsMention(CreateComment createComment) |
|||
{ |
|||
return createComment.IsMention; |
|||
} |
|||
|
|||
private async Task MentionUsersAsync(CreateComment createComment) |
|||
{ |
|||
if (!string.IsNullOrWhiteSpace(createComment.Text)) |
|||
{ |
|||
var emails = MentionRegex.Matches(createComment.Text).Select(x => x.Value.Substring(1)).ToArray(); |
|||
|
|||
if (emails.Length > 0) |
|||
{ |
|||
var mentions = new List<string>(); |
|||
|
|||
foreach (var email in emails) |
|||
{ |
|||
var user = await userResolver.FindByIdOrEmailAsync(email); |
|||
|
|||
if (user != null) |
|||
{ |
|||
mentions.Add(user.Id); |
|||
} |
|||
} |
|||
|
|||
if (mentions.Count > 0) |
|||
{ |
|||
createComment.Mentions = mentions.ToArray(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -1,19 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure.EventSourcing; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments.State |
|||
{ |
|||
public sealed class CommentsState : DomainObjectState<CommentsState> |
|||
{ |
|||
public override CommentsState Apply(Envelope<IEvent> @event) |
|||
{ |
|||
return this; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,102 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Mvc; |
|||
using Microsoft.Net.Http.Headers; |
|||
using Squidex.Areas.Api.Controllers.Comments.Models; |
|||
using Squidex.Domain.Apps.Entities.Comments; |
|||
using Squidex.Domain.Apps.Entities.Comments.Commands; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
using Squidex.Infrastructure.Security; |
|||
using Squidex.Web; |
|||
|
|||
namespace Squidex.Areas.Api.Controllers.Comments.Notifications |
|||
{ |
|||
/// <summary>
|
|||
/// Manages user notifications.
|
|||
/// </summary>
|
|||
[ApiExplorerSettings(GroupName = nameof(Notifications))] |
|||
public sealed class UserNotificationsController : ApiController |
|||
{ |
|||
private static readonly NamedId<Guid> NoApp = NamedId.Of(Guid.Empty, "none"); |
|||
private readonly ICommentsLoader commentsLoader; |
|||
|
|||
public UserNotificationsController(ICommandBus commandBus, ICommentsLoader commentsLoader) |
|||
: base(commandBus) |
|||
{ |
|||
this.commentsLoader = commentsLoader; |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Get all notifications.
|
|||
/// </summary>
|
|||
/// <param name="userId">The user id.</param>
|
|||
/// <param name="version">The current version.</param>
|
|||
/// <remarks>
|
|||
/// When passing in a version you can retrieve all updates since then.
|
|||
/// </remarks>
|
|||
/// <returns>
|
|||
/// 200 => All comments returned.
|
|||
/// </returns>
|
|||
[HttpGet] |
|||
[Route("users/{userId}/notifications")] |
|||
[ProducesResponseType(typeof(CommentsDto), 200)] |
|||
[ApiPermission] |
|||
public async Task<IActionResult> GetNotifications(string userId, [FromQuery] long version = EtagVersion.Any) |
|||
{ |
|||
CheckPermissions(userId); |
|||
|
|||
var result = await commentsLoader.GetCommentsAsync(userId, version); |
|||
|
|||
var response = Deferred.Response(() => |
|||
{ |
|||
return CommentsDto.FromResult(result); |
|||
}); |
|||
|
|||
Response.Headers[HeaderNames.ETag] = result.Version.ToString(); |
|||
|
|||
return Ok(response); |
|||
} |
|||
|
|||
/// <summary>
|
|||
/// Deletes the notification.
|
|||
/// </summary>
|
|||
/// <param name="userId">The user id.</param>
|
|||
/// <param name="commentId">The id of the comment.</param>
|
|||
/// <returns>
|
|||
/// 204 => Comment deleted.
|
|||
/// 404 => Comment not found.
|
|||
/// </returns>
|
|||
[HttpDelete] |
|||
[Route("users/{userId}/notifications/{commentId}")] |
|||
[ApiPermission] |
|||
public async Task<IActionResult> DeleteComment(string userId, Guid commentId) |
|||
{ |
|||
CheckPermissions(userId); |
|||
|
|||
await CommandBus.PublishAsync(new DeleteComment |
|||
{ |
|||
AppId = NoApp, |
|||
CommentsId = userId, |
|||
CommentId = commentId |
|||
}); |
|||
|
|||
return NoContent(); |
|||
} |
|||
|
|||
private void CheckPermissions(string userId) |
|||
{ |
|||
if (!string.Equals(userId, User.OpenIdSubject())) |
|||
{ |
|||
throw new DomainForbiddenException("You can only access your notifications."); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,180 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using FakeItEasy; |
|||
using Orleans; |
|||
using Squidex.Domain.Apps.Entities.Comments.Commands; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Commands; |
|||
using Squidex.Infrastructure.Orleans; |
|||
using Squidex.Infrastructure.Tasks; |
|||
using Squidex.Shared.Users; |
|||
using Xunit; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Comments |
|||
{ |
|||
public class CommentsCommandMiddlewareTests |
|||
{ |
|||
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>(); |
|||
private readonly IUserResolver userResolver = A.Fake<IUserResolver>(); |
|||
private readonly ICommandBus commandBus = A.Fake<ICommandBus>(); |
|||
private readonly RefToken actor = new RefToken(RefTokenType.Subject, "me"); |
|||
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app"); |
|||
private readonly Guid commentsId = Guid.NewGuid(); |
|||
private readonly Guid commentId = Guid.NewGuid(); |
|||
private readonly CommentsCommandMiddleware sut; |
|||
|
|||
public CommentsCommandMiddlewareTests() |
|||
{ |
|||
A.CallTo(() => userResolver.FindByIdOrEmailAsync(A<string>.Ignored)) |
|||
.Returns(Task.FromResult<IUser?>(null)); |
|||
|
|||
sut = new CommentsCommandMiddleware(grainFactory, userResolver); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_invoke_grain_for_comments_command() |
|||
{ |
|||
var command = CreateCommentsCommand(new CreateComment()); |
|||
var context = CreateContextForCommand(command); |
|||
|
|||
var grain = A.Fake<ICommentsGrain>(); |
|||
|
|||
var result = "Completed"; |
|||
|
|||
A.CallTo(() => grainFactory.GetGrain<ICommentsGrain>(commentsId.ToString(), null)) |
|||
.Returns(grain); |
|||
|
|||
A.CallTo(() => grain.ExecuteAsync(A<J<CommentsCommand>>.That.Matches(x => x.Value == command))) |
|||
.Returns(new J<object>(result)); |
|||
|
|||
var isNextCalled = false; |
|||
|
|||
await sut.HandleAsync(context, () => |
|||
{ |
|||
isNextCalled = true; |
|||
|
|||
return TaskHelper.Done; |
|||
}); |
|||
|
|||
Assert.True(isNextCalled); |
|||
|
|||
A.CallTo(() => grain.ExecuteAsync(A<J<CommentsCommand>>.That.Matches(x => x.Value == command))) |
|||
.Returns(new J<object>(12)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_enrich_with_mentioned_user_ids_if_found() |
|||
{ |
|||
SetupUser("id1", "mail1@squidex.io"); |
|||
SetupUser("id2", "mail2@squidex.io"); |
|||
|
|||
var command = CreateCommentsCommand(new CreateComment |
|||
{ |
|||
Text = "Hi @mail1@squidex.io, @mail2@squidex.io and @notfound@squidex.io" |
|||
}); |
|||
|
|||
var context = CreateContextForCommand(command); |
|||
|
|||
await sut.HandleAsync(context); |
|||
|
|||
Assert.Equal(command.Mentions, new[] { "id1", "id2" }); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_invoke_commands_for_mentioned_users() |
|||
{ |
|||
SetupUser("id1", "mail1@squidex.io"); |
|||
SetupUser("id2", "mail2@squidex.io"); |
|||
|
|||
var command = CreateCommentsCommand(new CreateComment |
|||
{ |
|||
Text = "Hi @mail1@squidex.io and @mail2@squidex.io" |
|||
}); |
|||
|
|||
var context = CreateContextForCommand(command); |
|||
|
|||
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(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_enrich_with_mentioned_user_ids_if_invalid_mentioned_tags_used() |
|||
{ |
|||
var command = CreateCommentsCommand(new CreateComment |
|||
{ |
|||
Text = "Hi invalid@squidex.io" |
|||
}); |
|||
|
|||
var context = CreateContextForCommand(command); |
|||
|
|||
await sut.HandleAsync(context); |
|||
|
|||
A.CallTo(() => userResolver.FindByIdOrEmailAsync(A<string>.Ignored)) |
|||
.MustNotHaveHappened(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_enrich_with_mentioned_user_ids_for_notification() |
|||
{ |
|||
var command = new CreateComment |
|||
{ |
|||
Text = "Hi @invalid@squidex.io", IsMention = true |
|||
}; |
|||
|
|||
var context = CreateContextForCommand(command); |
|||
|
|||
await sut.HandleAsync(context); |
|||
|
|||
A.CallTo(() => userResolver.FindByIdOrEmailAsync(A<string>.Ignored)) |
|||
.MustNotHaveHappened(); |
|||
} |
|||
|
|||
protected CommandContext CreateContextForCommand<TCommand>(TCommand command) where TCommand : CommentsCommand |
|||
{ |
|||
return new CommandContext(command, commandBus); |
|||
} |
|||
|
|||
private static bool IsForUser(ICommand command, string id) |
|||
{ |
|||
return command is CreateComment createComment && |
|||
createComment.CommentsId == id && |
|||
createComment.Mentions == null && |
|||
createComment.AppId == null && |
|||
createComment.ExpectedVersion == EtagVersion.Any && |
|||
createComment.IsMention; |
|||
} |
|||
|
|||
private void SetupUser(string id, string email) |
|||
{ |
|||
var user = A.Fake<IUser>(); |
|||
|
|||
A.CallTo(() => user.Id).Returns(id); |
|||
A.CallTo(() => user.Email).Returns(email); |
|||
|
|||
A.CallTo(() => userResolver.FindByIdOrEmailAsync(email)) |
|||
.Returns(user); |
|||
} |
|||
|
|||
protected T CreateCommentsCommand<T>(T command) where T : CommentsCommand |
|||
{ |
|||
command.Actor = actor; |
|||
command.AppId = appId; |
|||
command.CommentsId = commentsId.ToString(); |
|||
command.CommentId = commentId; |
|||
|
|||
return command; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,28 @@ |
|||
<ul class="nav navbar-nav"> |
|||
<li class="nav-item nav-icon dropdown"> |
|||
<span class="nav-link dropdown-toggle" (click)="modalMenu.show()"> |
|||
<i class="icon-comments"></i> |
|||
|
|||
<span class="badge badge-pill" *ngIf="unread">{{unread}}</span> |
|||
</span> |
|||
|
|||
<ng-container *sqxModal="modalMenu;onRoot:false"> |
|||
<div class="dropdown-menu" #scrollMe [scrollTop]="scrollMe.scrollHeight" @fade> |
|||
<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" |
|||
[confirmDelete]="false" |
|||
[canDelete]="true" |
|||
[canFollow]="true" |
|||
(delete)="delete(comment)" |
|||
[userToken]="userToken"> |
|||
</sqx-comment> |
|||
</ng-container> |
|||
</div> |
|||
</ng-container> |
|||
</li> |
|||
</ul> |
|||
@ -0,0 +1,13 @@ |
|||
@import '_vars'; |
|||
@import '_mixins'; |
|||
|
|||
.dropdown-menu { |
|||
left: auto; |
|||
max-height: 500px; |
|||
min-height: 5rem; |
|||
overflow-y: scroll; |
|||
padding: 1.5rem; |
|||
padding-bottom: 1rem; |
|||
right: 0; |
|||
width: 300px; |
|||
} |
|||
@ -0,0 +1,110 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core'; |
|||
import { timer } from 'rxjs'; |
|||
import { onErrorResumeNext, switchMap, tap } from 'rxjs/operators'; |
|||
|
|||
import { |
|||
AuthService, |
|||
CommentDto, |
|||
CommentsService, |
|||
CommentsState, |
|||
DialogService, |
|||
fadeAnimation, |
|||
LocalStoreService, |
|||
ModalModel, |
|||
ResourceOwner |
|||
} from '@app/shared'; |
|||
|
|||
@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; |
|||
private configKey: string; |
|||
|
|||
public modalMenu = new ModalModel(); |
|||
|
|||
public commentsUrl: string; |
|||
public commentsState: CommentsState; |
|||
|
|||
public userId: string; |
|||
public userToken: string; |
|||
|
|||
public versionRead = -1; |
|||
public versionReceived = -1; |
|||
|
|||
public get unread() { |
|||
return Math.max(0, this.versionReceived - this.versionRead); |
|||
} |
|||
|
|||
constructor(authService: AuthService, |
|||
private readonly changeDetector: ChangeDetectorRef, |
|||
private readonly commentsService: CommentsService, |
|||
private readonly dialogs: DialogService, |
|||
private readonly localStore: LocalStoreService |
|||
) { |
|||
super(); |
|||
|
|||
this.userToken = authService.user!.token; |
|||
this.userId = authService.user!.id; |
|||
|
|||
this.configKey = `users.${this.userId}.notifications`; |
|||
|
|||
this.versionRead = localStore.getInt(this.configKey, -1); |
|||
this.versionReceived = this.versionRead; |
|||
} |
|||
|
|||
public ngOnInit() { |
|||
this.commentsUrl = `users/${this.userId}/notifications`; |
|||
this.commentsState = new CommentsState(this.commentsUrl, this.commentsService, this.dialogs); |
|||
|
|||
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 delete(comment: CommentDto) { |
|||
this.commentsState.delete(comment); |
|||
} |
|||
|
|||
public trackByComment(comment: CommentDto) { |
|||
return comment.id; |
|||
} |
|||
|
|||
private updateVersion() { |
|||
if (this.isOpen) { |
|||
this.versionRead = this.versionReceived; |
|||
|
|||
this.localStore.setInt(this.configKey, this.versionRead); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue