Skip to content

Commit

Permalink
Add user preferences feature (#45)
Browse files Browse the repository at this point in the history
  • Loading branch information
Bardin08 authored Jun 8, 2024
1 parent 6388789 commit c9810f6
Show file tree
Hide file tree
Showing 23 changed files with 461 additions and 35 deletions.
5 changes: 5 additions & 0 deletions src/Api/Endpoints/Auth/RegisterEndpoint.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Api.Models.Requests.Auth;
using Api.Models.Responses;
using Core.Abstractions;
using FastEndpoints;
using Infrastructure.Auth0.Abstractions;
using Infrastructure.Auth0.Models;
Expand All @@ -8,10 +9,12 @@ namespace Api.Endpoints.Auth;

public class RegisterEndpoint(
IAuthService authService,
IUserPreferencesService userPreferencesService,
ILogger<RegisterEndpoint> logger)
: Endpoint<RegisterRequest, ApiResponse<UserInfoResponse>>
{
private readonly IAuthService _authService = authService;
private readonly IUserPreferencesService _userPreferencesService = userPreferencesService;
private readonly ILogger<RegisterEndpoint> _logger = logger;

public override void Configure()
Expand All @@ -35,6 +38,8 @@ public override async Task HandleAsync(
}
else
{
await _userPreferencesService.CreateUserPreferences(registrationResponse.UserId, ct);

var tokenInfo = new UserTokenResponse(
registrationResponse.TokenInfo.Token,
registrationResponse.TokenInfo.ExpiresIn);
Expand Down
28 changes: 28 additions & 0 deletions src/Api/Endpoints/Preferences/UpdatePreferencesEndpoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Api.Mappers.UserPreferences;
using Api.Models.Requests.UserPreferences;
using Api.Models.Responses;
using Core.Abstractions;
using FastEndpoints;

namespace Api.Endpoints.Preferences;

public class UpdatePreferencesEndpoint(IUserPreferencesService userPreferencesService) :
Endpoint<UpdateUserPreferencesRequest, ApiResponse, UpdateUserPreferencesMapper>
{
private readonly IUserPreferencesService _preferencesService = userPreferencesService;

public override void Configure()
{
Verbs(Http.PUT);
Routes("api/preferences");
}

public override async Task HandleAsync(UpdateUserPreferencesRequest req, CancellationToken ct)
{
var dto = Map.ToEntity(req).Value;
var result = await _preferencesService.UpdateUserPreferences(dto, ct);

var apiResponse = Map.FromEntity(result);
await SendAsync(apiResponse, cancellation: ct);
}
}
2 changes: 1 addition & 1 deletion src/Api/Extensions/ErrorOrExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ internal static ApiResponse<TResponse> ToApiResponse<TEntity, TResponse>(
return ApiResponse<TResponse>.Success(templateResponse);
}

private static IEnumerable<ProblemDetails.Error> ToProblemDetailsErrors<T>(this ErrorOr<T> errorOr)
internal static IEnumerable<ProblemDetails.Error> ToProblemDetailsErrors<T>(this ErrorOr<T> errorOr)
{
if (!errorOr.IsError)
throw new ArgumentException("DU contains value, not error");
Expand Down
43 changes: 43 additions & 0 deletions src/Api/Mappers/UserPreferences/UpdateUserPreferencesMapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Api.Extensions;
using Api.Models.Requests.UserPreferences;
using Api.Models.Responses;
using Core.Models.UserPreferences;
using ErrorOr;
using FastEndpoints;

namespace Api.Mappers.UserPreferences;

public class UpdateUserPreferencesMapper
: Mapper<UpdateUserPreferencesRequest, ApiResponse, ErrorOr<UserPreferencesDto>>
{
public override ErrorOr<UserPreferencesDto> ToEntity(UpdateUserPreferencesRequest r)
{
return new UserPreferencesDto
{
UserId = r.UserId,
Channels = r.Channels.ToDictionary(
kvp => kvp.Key,
kvp => new ChannelDescriptorBaseDto
{
Enabled = kvp.Value.Enabled,
Description = kvp.Value.Description,
Metadata = kvp.Value.Metadata
})
};
}

public override ApiResponse FromEntity(ErrorOr<UserPreferencesDto> e)
{
if (e.IsError)
{
var problemDetails = new ProblemDetails
{
Errors = e.ToProblemDetailsErrors()
};

return ApiResponse.Fail(problemDetails);
}

return ApiResponse.Success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Api.Models.Requests.UserPreferences;

public record ChannelDescriptorBaseRequest
{
public bool Enabled { get; init; }
public string? Description { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace Api.Models.Requests.UserPreferences;

public class UpdateUserPreferencesRequest
{
public required string UserId { get; set; }
public required Dictionary<string, ChannelDescriptorBaseRequest> Channels { get; set; }
}
13 changes: 13 additions & 0 deletions src/Core/Abstractions/IUserPreferencesService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Core.Models.UserPreferences;
using ErrorOr;

namespace Core.Abstractions;

public interface IUserPreferencesService
{
Task<ErrorOr<UserPreferencesDto>> CreateUserPreferences(string userId, CancellationToken ct);
Task<ErrorOr<UserPreferencesDto>> UpdateUserPreferences(
UserPreferencesDto userPreferences, CancellationToken ct);
Task<ErrorOr<ChannelDescriptorBaseDto>> GetChannelDeliveryInfo(
string recipientUserId, string channel, CancellationToken ct);
}
1 change: 1 addition & 0 deletions src/Core/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ public static void AddGatewayCore(this IServiceCollection services, IConfigurati
{
services.AddScoped<ITemplatesService, TemplatesService>();
services.AddScoped<INotificationsService, NotificationService>();
services.AddScoped<IUserPreferencesService, UserPreferencesService>();
}
}
2 changes: 1 addition & 1 deletion src/Core/Errors/TemplatesErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ namespace Core.Errors;
internal static class TemplatesErrors
{
internal static ErrorOr<TemplateDto> NameDuplication
=> Error.Conflict("Template.NameDuplication", "Template with same name already exists");
=> Error.Conflict("Template.FailedToCreate", "Template with same name already exists");
}
19 changes: 19 additions & 0 deletions src/Core/Errors/UserPreferencesErrors.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Core.Models.UserPreferences;
using ErrorOr;

namespace Core.Errors;

public static class UserPreferencesErrors
{
internal static ErrorOr<UserPreferencesDto> FailedToCreate
=> Error.Unexpected("UserPreferences.CreateFailed", "Failed to create user preferences");

internal static ErrorOr<UserPreferencesDto> FailedToUpdate
=> Error.Unexpected("UserPreferences.CreateUpdate", "Failed to update user preferences");

internal static ErrorOr<UserPreferencesDto> NotFound
=> Error.NotFound("UserPreferences.NotFound", "User preferences not found");

internal static ErrorOr<ChannelDescriptorBaseDto> ChannelNotFound
=> Error.NotFound("UserPreferences.Channel.NotFound", "User preferences for channel not found");
}
27 changes: 27 additions & 0 deletions src/Core/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Globalization;
using System.Text;

namespace Core.Extensions;

public static class StringExtensions
{
public static string ToPascalCase(this string text)
{
if (text.Length < 1)
return text;

var words = text.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries);

var sb = new StringBuilder();

foreach (var word in words)
{
if (word.Length > 0)
{
sb.Append(CultureInfo.InvariantCulture.TextInfo.ToTitleCase(word.ToLowerInvariant()));
}
}

return sb.ToString();
}
}
95 changes: 95 additions & 0 deletions src/Core/Mappers/UserPreferencesMapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
using Core.Extensions;
using Core.Models.UserPreferences;
using Infrastructure.Persistence.Mongo.Entities.Preferences;
using MongoDB.Bson;

namespace Core.Mappers;

public static class UserPreferencesChannelMapper
{
public static ChannelDescriptorBaseDto ToDto(ChannelDescriptorBase e)
{
return new ChannelDescriptorBaseDto
{
Enabled = e.Enabled,
Description = e.Description,
Metadata = e.Metadata
};
}

public static ChannelDescriptorBase ToEntity(ChannelDescriptorBaseDto dto)
{
return new ChannelDescriptorBase
{
Enabled = dto.Enabled,
Description = dto.Description,
Metadata = dto.Metadata
};
}
}

public static class UserPreferencesMapper
{
internal static UserPreferencesDto ToDto(UserPreferences e)
{
return new UserPreferencesDto
{
Id = e.Id.ToString(),
UserId = e.UserId,
Channels = e.Channels.ToDictionary(x => x.Key, d => UserPreferencesChannelMapper.ToDto(d.Value))
};
}

public static UserPreferences UpdateEntity(UserPreferences e, UserPreferencesDto dto)
{
var channels = e.Channels;
foreach (var ch in dto.Channels)
{
var metadataKeys = ch.Value.Metadata?.Keys.ToArray()!;
foreach (var mk in metadataKeys)
{
var normalized = mk.ToPascalCase();

if (!mk.Equals(normalized))
{
ch.Value.Metadata![normalized] = ch.Value.Metadata[mk];
ch.Value.Metadata.Remove(mk);
}
}

var normalizedKey = ch.Key.ToPascalCase();
if (channels.TryGetValue(normalizedKey, out var channel))
{
channel.Enabled = ch.Value.Enabled;
channel.Description = ch.Value.Description;
channel.Metadata = ch.Value.Metadata;
}
else
{
channels.Add(normalizedKey, new ChannelDescriptorBase
{
Enabled = ch.Value.Enabled,
Description = ch.Value.Description,
Metadata = ch.Value.Metadata
});
}
}

return e with { LastUpdated = DateTimeOffset.UtcNow, Channels = channels };
}

public static UserPreferences ToEntity(UserPreferencesDto dto)
{
var id = string.IsNullOrEmpty(dto.Id)
? ObjectId.GenerateNewId()
: ObjectId.Parse(dto.Id);

return new UserPreferences
{
Id = id,
UserId = dto.UserId,
Channels = dto.Channels.ToDictionary(x => x.Key, d => UserPreferencesChannelMapper.ToEntity(d.Value)),
LastUpdated = DateTimeOffset.UtcNow
};
}
}
8 changes: 8 additions & 0 deletions src/Core/Models/UserPreferences/ChannelDescriptorBaseDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Core.Models.UserPreferences;

public record ChannelDescriptorBaseDto
{
public bool Enabled { get; init; }
public string? Description { get; init; }
public Dictionary<string, string>? Metadata { get; init; }
}
8 changes: 8 additions & 0 deletions src/Core/Models/UserPreferences/UserPreferencesDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Core.Models.UserPreferences;

public record UserPreferencesDto
{
public string? Id { get; init; }
public required string UserId { get; init; }
public required Dictionary<string, ChannelDescriptorBaseDto> Channels { get; init; }
}
34 changes: 30 additions & 4 deletions src/Core/Services/NotificationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ namespace Core.Services;

internal class NotificationService(
ITemplateFillerClient massTransitClient,
IUserPreferencesService userPreferencesService,
INotificationsAnalyticsClient notificationsAnalyticsClient,
INotificationsRepository notificationsRepository)
: INotificationsService
Expand All @@ -24,7 +25,7 @@ public async Task<ErrorOr<NotificationDto>> CreateNotification(
await notificationsRepository.InsertOne(notification);
dto = dto with { Id = notification.Id.ToString() };

var deliveryRequests = CreateDeliveryRequests(dto).ToList();
var deliveryRequests = await CreateDeliveryRequests(dto, ct);

await massTransitClient.SendMessages(deliveryRequests, string.Empty);
foreach (var deliveryRequest in deliveryRequests)
Expand All @@ -36,10 +37,35 @@ await notificationsAnalyticsClient
return dto;
}

private IEnumerable<Notification> CreateDeliveryRequests(NotificationDto dto)
private async Task<List<Notification>> CreateDeliveryRequests(NotificationDto dto, CancellationToken ct)
{
return dto.Recipients!
return await dto.Recipients!
.ToAsyncEnumerable()
.Select(recipient => Mappers.External.DeliveryRequestMapper.ToRequest(dto, recipient))
.ToList();
.SelectAwait(deliveryRequest => PopulateDeliveryInfo(deliveryRequest, ct))
.ToListAsync(cancellationToken: ct);
}

private async ValueTask<Notification> PopulateDeliveryInfo(Notification notification, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();

var channel = notification.Recipient.Channel;
var deliveryInfo = await userPreferencesService
.GetChannelDeliveryInfo(notification.Recipient.UserId, channel, ct);
if (deliveryInfo.IsError)
{
return notification;
}

notification = notification with
{
Recipient = notification.Recipient with
{
DeliveryInfo = deliveryInfo.Value
}
};

return notification;
}
}
Loading

0 comments on commit c9810f6

Please sign in to comment.