Skip to content

Commit c9810f6

Browse files
authored
Add user preferences feature (#45)
1 parent 6388789 commit c9810f6

23 files changed

+461
-35
lines changed

src/Api/Endpoints/Auth/RegisterEndpoint.cs

+5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Api.Models.Requests.Auth;
22
using Api.Models.Responses;
3+
using Core.Abstractions;
34
using FastEndpoints;
45
using Infrastructure.Auth0.Abstractions;
56
using Infrastructure.Auth0.Models;
@@ -8,10 +9,12 @@ namespace Api.Endpoints.Auth;
89

910
public class RegisterEndpoint(
1011
IAuthService authService,
12+
IUserPreferencesService userPreferencesService,
1113
ILogger<RegisterEndpoint> logger)
1214
: Endpoint<RegisterRequest, ApiResponse<UserInfoResponse>>
1315
{
1416
private readonly IAuthService _authService = authService;
17+
private readonly IUserPreferencesService _userPreferencesService = userPreferencesService;
1518
private readonly ILogger<RegisterEndpoint> _logger = logger;
1619

1720
public override void Configure()
@@ -35,6 +38,8 @@ public override async Task HandleAsync(
3538
}
3639
else
3740
{
41+
await _userPreferencesService.CreateUserPreferences(registrationResponse.UserId, ct);
42+
3843
var tokenInfo = new UserTokenResponse(
3944
registrationResponse.TokenInfo.Token,
4045
registrationResponse.TokenInfo.ExpiresIn);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Api.Mappers.UserPreferences;
2+
using Api.Models.Requests.UserPreferences;
3+
using Api.Models.Responses;
4+
using Core.Abstractions;
5+
using FastEndpoints;
6+
7+
namespace Api.Endpoints.Preferences;
8+
9+
public class UpdatePreferencesEndpoint(IUserPreferencesService userPreferencesService) :
10+
Endpoint<UpdateUserPreferencesRequest, ApiResponse, UpdateUserPreferencesMapper>
11+
{
12+
private readonly IUserPreferencesService _preferencesService = userPreferencesService;
13+
14+
public override void Configure()
15+
{
16+
Verbs(Http.PUT);
17+
Routes("api/preferences");
18+
}
19+
20+
public override async Task HandleAsync(UpdateUserPreferencesRequest req, CancellationToken ct)
21+
{
22+
var dto = Map.ToEntity(req).Value;
23+
var result = await _preferencesService.UpdateUserPreferences(dto, ct);
24+
25+
var apiResponse = Map.FromEntity(result);
26+
await SendAsync(apiResponse, cancellation: ct);
27+
}
28+
}

src/Api/Extensions/ErrorOrExtensions.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ internal static ApiResponse<TResponse> ToApiResponse<TEntity, TResponse>(
2424
return ApiResponse<TResponse>.Success(templateResponse);
2525
}
2626

27-
private static IEnumerable<ProblemDetails.Error> ToProblemDetailsErrors<T>(this ErrorOr<T> errorOr)
27+
internal static IEnumerable<ProblemDetails.Error> ToProblemDetailsErrors<T>(this ErrorOr<T> errorOr)
2828
{
2929
if (!errorOr.IsError)
3030
throw new ArgumentException("DU contains value, not error");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using Api.Extensions;
2+
using Api.Models.Requests.UserPreferences;
3+
using Api.Models.Responses;
4+
using Core.Models.UserPreferences;
5+
using ErrorOr;
6+
using FastEndpoints;
7+
8+
namespace Api.Mappers.UserPreferences;
9+
10+
public class UpdateUserPreferencesMapper
11+
: Mapper<UpdateUserPreferencesRequest, ApiResponse, ErrorOr<UserPreferencesDto>>
12+
{
13+
public override ErrorOr<UserPreferencesDto> ToEntity(UpdateUserPreferencesRequest r)
14+
{
15+
return new UserPreferencesDto
16+
{
17+
UserId = r.UserId,
18+
Channels = r.Channels.ToDictionary(
19+
kvp => kvp.Key,
20+
kvp => new ChannelDescriptorBaseDto
21+
{
22+
Enabled = kvp.Value.Enabled,
23+
Description = kvp.Value.Description,
24+
Metadata = kvp.Value.Metadata
25+
})
26+
};
27+
}
28+
29+
public override ApiResponse FromEntity(ErrorOr<UserPreferencesDto> e)
30+
{
31+
if (e.IsError)
32+
{
33+
var problemDetails = new ProblemDetails
34+
{
35+
Errors = e.ToProblemDetailsErrors()
36+
};
37+
38+
return ApiResponse.Fail(problemDetails);
39+
}
40+
41+
return ApiResponse.Success();
42+
}
43+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Api.Models.Requests.UserPreferences;
2+
3+
public record ChannelDescriptorBaseRequest
4+
{
5+
public bool Enabled { get; init; }
6+
public string? Description { get; init; }
7+
public Dictionary<string, string>? Metadata { get; init; }
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace Api.Models.Requests.UserPreferences;
2+
3+
public class UpdateUserPreferencesRequest
4+
{
5+
public required string UserId { get; set; }
6+
public required Dictionary<string, ChannelDescriptorBaseRequest> Channels { get; set; }
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using Core.Models.UserPreferences;
2+
using ErrorOr;
3+
4+
namespace Core.Abstractions;
5+
6+
public interface IUserPreferencesService
7+
{
8+
Task<ErrorOr<UserPreferencesDto>> CreateUserPreferences(string userId, CancellationToken ct);
9+
Task<ErrorOr<UserPreferencesDto>> UpdateUserPreferences(
10+
UserPreferencesDto userPreferences, CancellationToken ct);
11+
Task<ErrorOr<ChannelDescriptorBaseDto>> GetChannelDeliveryInfo(
12+
string recipientUserId, string channel, CancellationToken ct);
13+
}

src/Core/DependencyInjection.cs

+1
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ public static void AddGatewayCore(this IServiceCollection services, IConfigurati
1111
{
1212
services.AddScoped<ITemplatesService, TemplatesService>();
1313
services.AddScoped<INotificationsService, NotificationService>();
14+
services.AddScoped<IUserPreferencesService, UserPreferencesService>();
1415
}
1516
}

src/Core/Errors/TemplatesErrors.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@ namespace Core.Errors;
66
internal static class TemplatesErrors
77
{
88
internal static ErrorOr<TemplateDto> NameDuplication
9-
=> Error.Conflict("Template.NameDuplication", "Template with same name already exists");
9+
=> Error.Conflict("Template.FailedToCreate", "Template with same name already exists");
1010
}
+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using Core.Models.UserPreferences;
2+
using ErrorOr;
3+
4+
namespace Core.Errors;
5+
6+
public static class UserPreferencesErrors
7+
{
8+
internal static ErrorOr<UserPreferencesDto> FailedToCreate
9+
=> Error.Unexpected("UserPreferences.CreateFailed", "Failed to create user preferences");
10+
11+
internal static ErrorOr<UserPreferencesDto> FailedToUpdate
12+
=> Error.Unexpected("UserPreferences.CreateUpdate", "Failed to update user preferences");
13+
14+
internal static ErrorOr<UserPreferencesDto> NotFound
15+
=> Error.NotFound("UserPreferences.NotFound", "User preferences not found");
16+
17+
internal static ErrorOr<ChannelDescriptorBaseDto> ChannelNotFound
18+
=> Error.NotFound("UserPreferences.Channel.NotFound", "User preferences for channel not found");
19+
}
+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System.Globalization;
2+
using System.Text;
3+
4+
namespace Core.Extensions;
5+
6+
public static class StringExtensions
7+
{
8+
public static string ToPascalCase(this string text)
9+
{
10+
if (text.Length < 1)
11+
return text;
12+
13+
var words = text.Split(new[] { ' ', '-', '_', '.' }, StringSplitOptions.RemoveEmptyEntries);
14+
15+
var sb = new StringBuilder();
16+
17+
foreach (var word in words)
18+
{
19+
if (word.Length > 0)
20+
{
21+
sb.Append(CultureInfo.InvariantCulture.TextInfo.ToTitleCase(word.ToLowerInvariant()));
22+
}
23+
}
24+
25+
return sb.ToString();
26+
}
27+
}
+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
using Core.Extensions;
2+
using Core.Models.UserPreferences;
3+
using Infrastructure.Persistence.Mongo.Entities.Preferences;
4+
using MongoDB.Bson;
5+
6+
namespace Core.Mappers;
7+
8+
public static class UserPreferencesChannelMapper
9+
{
10+
public static ChannelDescriptorBaseDto ToDto(ChannelDescriptorBase e)
11+
{
12+
return new ChannelDescriptorBaseDto
13+
{
14+
Enabled = e.Enabled,
15+
Description = e.Description,
16+
Metadata = e.Metadata
17+
};
18+
}
19+
20+
public static ChannelDescriptorBase ToEntity(ChannelDescriptorBaseDto dto)
21+
{
22+
return new ChannelDescriptorBase
23+
{
24+
Enabled = dto.Enabled,
25+
Description = dto.Description,
26+
Metadata = dto.Metadata
27+
};
28+
}
29+
}
30+
31+
public static class UserPreferencesMapper
32+
{
33+
internal static UserPreferencesDto ToDto(UserPreferences e)
34+
{
35+
return new UserPreferencesDto
36+
{
37+
Id = e.Id.ToString(),
38+
UserId = e.UserId,
39+
Channels = e.Channels.ToDictionary(x => x.Key, d => UserPreferencesChannelMapper.ToDto(d.Value))
40+
};
41+
}
42+
43+
public static UserPreferences UpdateEntity(UserPreferences e, UserPreferencesDto dto)
44+
{
45+
var channels = e.Channels;
46+
foreach (var ch in dto.Channels)
47+
{
48+
var metadataKeys = ch.Value.Metadata?.Keys.ToArray()!;
49+
foreach (var mk in metadataKeys)
50+
{
51+
var normalized = mk.ToPascalCase();
52+
53+
if (!mk.Equals(normalized))
54+
{
55+
ch.Value.Metadata![normalized] = ch.Value.Metadata[mk];
56+
ch.Value.Metadata.Remove(mk);
57+
}
58+
}
59+
60+
var normalizedKey = ch.Key.ToPascalCase();
61+
if (channels.TryGetValue(normalizedKey, out var channel))
62+
{
63+
channel.Enabled = ch.Value.Enabled;
64+
channel.Description = ch.Value.Description;
65+
channel.Metadata = ch.Value.Metadata;
66+
}
67+
else
68+
{
69+
channels.Add(normalizedKey, new ChannelDescriptorBase
70+
{
71+
Enabled = ch.Value.Enabled,
72+
Description = ch.Value.Description,
73+
Metadata = ch.Value.Metadata
74+
});
75+
}
76+
}
77+
78+
return e with { LastUpdated = DateTimeOffset.UtcNow, Channels = channels };
79+
}
80+
81+
public static UserPreferences ToEntity(UserPreferencesDto dto)
82+
{
83+
var id = string.IsNullOrEmpty(dto.Id)
84+
? ObjectId.GenerateNewId()
85+
: ObjectId.Parse(dto.Id);
86+
87+
return new UserPreferences
88+
{
89+
Id = id,
90+
UserId = dto.UserId,
91+
Channels = dto.Channels.ToDictionary(x => x.Key, d => UserPreferencesChannelMapper.ToEntity(d.Value)),
92+
LastUpdated = DateTimeOffset.UtcNow
93+
};
94+
}
95+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Core.Models.UserPreferences;
2+
3+
public record ChannelDescriptorBaseDto
4+
{
5+
public bool Enabled { get; init; }
6+
public string? Description { get; init; }
7+
public Dictionary<string, string>? Metadata { get; init; }
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Core.Models.UserPreferences;
2+
3+
public record UserPreferencesDto
4+
{
5+
public string? Id { get; init; }
6+
public required string UserId { get; init; }
7+
public required Dictionary<string, ChannelDescriptorBaseDto> Channels { get; init; }
8+
}

src/Core/Services/NotificationService.cs

+30-4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ namespace Core.Services;
1111

1212
internal class NotificationService(
1313
ITemplateFillerClient massTransitClient,
14+
IUserPreferencesService userPreferencesService,
1415
INotificationsAnalyticsClient notificationsAnalyticsClient,
1516
INotificationsRepository notificationsRepository)
1617
: INotificationsService
@@ -24,7 +25,7 @@ public async Task<ErrorOr<NotificationDto>> CreateNotification(
2425
await notificationsRepository.InsertOne(notification);
2526
dto = dto with { Id = notification.Id.ToString() };
2627

27-
var deliveryRequests = CreateDeliveryRequests(dto).ToList();
28+
var deliveryRequests = await CreateDeliveryRequests(dto, ct);
2829

2930
await massTransitClient.SendMessages(deliveryRequests, string.Empty);
3031
foreach (var deliveryRequest in deliveryRequests)
@@ -36,10 +37,35 @@ await notificationsAnalyticsClient
3637
return dto;
3738
}
3839

39-
private IEnumerable<Notification> CreateDeliveryRequests(NotificationDto dto)
40+
private async Task<List<Notification>> CreateDeliveryRequests(NotificationDto dto, CancellationToken ct)
4041
{
41-
return dto.Recipients!
42+
return await dto.Recipients!
43+
.ToAsyncEnumerable()
4244
.Select(recipient => Mappers.External.DeliveryRequestMapper.ToRequest(dto, recipient))
43-
.ToList();
45+
.SelectAwait(deliveryRequest => PopulateDeliveryInfo(deliveryRequest, ct))
46+
.ToListAsync(cancellationToken: ct);
47+
}
48+
49+
private async ValueTask<Notification> PopulateDeliveryInfo(Notification notification, CancellationToken ct)
50+
{
51+
ct.ThrowIfCancellationRequested();
52+
53+
var channel = notification.Recipient.Channel;
54+
var deliveryInfo = await userPreferencesService
55+
.GetChannelDeliveryInfo(notification.Recipient.UserId, channel, ct);
56+
if (deliveryInfo.IsError)
57+
{
58+
return notification;
59+
}
60+
61+
notification = notification with
62+
{
63+
Recipient = notification.Recipient with
64+
{
65+
DeliveryInfo = deliveryInfo.Value
66+
}
67+
};
68+
69+
return notification;
4470
}
4571
}

0 commit comments

Comments
 (0)