From f2159b7259a5659d8be8fc5f7f58e60ad1e6a1fa Mon Sep 17 00:00:00 2001 From: Marco van Kimmenade Date: Tue, 5 Nov 2024 12:16:52 +0100 Subject: [PATCH] Add configurable local timezone to user profile for accurate calendar scheduling (#51) ### Summary & Motivation Introduce a user-configurable windows timezone field within the user profile to ensure that meetings are scheduled accurately in the organizer's local time zone. This enhancement allows the system to capture and utilize both the user's timezone and timestamp when creating meetings, helping avoid potential time discrepancies due to timezone offsets. Internally, this approach standardizes the management of timezone information, enabling seamless conversion to UTC offsets when needed. Additionally, this PR consolidates all `AccountManagement` system internal calls under `AccountManagementClient`, centralizing the API interactions and reducing coupling within the system. ### Atomic Changes - Move all `AccountManagement` system internal calls to the `AccountManagementClient` - Store local IANA time zone in user domain and use it for meeting creation - Add timezone as field to edit in `UserProfile` ### Checklist - [x] I have added a Label to the pull-request - [x] I have added tests, and done manual regression tests - [x] I have updated the documentation, if necessary --- application/Directory.Packages.props | 1 + .../Api/Endpoints/UserEndpoints.cs | 4 + .../Core/AccountManagement.csproj | 4 + .../Services/AuthenticationTokenGenerator.cs | 3 +- .../20241007_InitialMigration.cs} | 4 +- .../20241102_AddUserLocalTimeZoneId.cs | 14 ++++ .../Core/Users/Commands/UpdateUser.cs | 3 + .../Core/Users/Domain/User.cs | 7 ++ .../Core/Users/Queries/GetTimezones.cs | 25 ++++++ .../Core/Users/Queries/GetUser.cs | 3 +- .../Core/Users/Queries/GetUserByEmailQuery.cs | 3 +- .../Tests/Users/GetUserTests.cs | 1 + .../Tests/Users/UpdateUserTests.cs | 3 +- .../userModals/UserProfileModal.tsx | 19 +++++ .../shared/lib/api/AccountManagement.Api.json | 56 +++++++++++++ .../shared/translations/locale/da-DK.po | 3 + .../shared/translations/locale/en-US.po | 3 + .../shared/translations/locale/nl-NL.po | 3 + .../Core/AI/Planner/ActionPlanner.cs | 49 ++++++------ .../Application/Users/Commands/CreateUser.cs | 70 ++++++---------- .../Users/Queries/GetUserInfoByEmail.cs | 47 +++-------- .../Core/CalendarAssistant.csproj | 1 + .../AccountManagementClient.cs | 80 ++++++++++++++++++- .../Calendar/Microsoft365CalendarClient.cs | 11 +-- .../Extensions/DateTimeOffsetExtensions.cs | 14 ++++ .../Core/Extensions/EmailExtensions.cs | 27 +++++++ .../Authentication/AuthenticationManager.cs | 6 +- .../SharedKernel/Authentication/UserInfo.cs | 7 +- 28 files changed, 342 insertions(+), 129 deletions(-) rename application/account-management/Core/Database/{DatabaseMigrations.cs => DatabaseMigrations/20241007_InitialMigration.cs} (97%) create mode 100644 application/account-management/Core/Database/DatabaseMigrations/20241102_AddUserLocalTimeZoneId.cs create mode 100644 application/account-management/Core/Users/Queries/GetTimezones.cs create mode 100644 application/calendar-assistant/Core/Extensions/DateTimeOffsetExtensions.cs create mode 100644 application/calendar-assistant/Core/Extensions/EmailExtensions.cs diff --git a/application/Directory.Packages.props b/application/Directory.Packages.props index beb662301..027d8baf1 100644 --- a/application/Directory.Packages.props +++ b/application/Directory.Packages.props @@ -80,6 +80,7 @@ + all diff --git a/application/account-management/Api/Endpoints/UserEndpoints.cs b/application/account-management/Api/Endpoints/UserEndpoints.cs index b1849b5f2..60e531f4e 100644 --- a/application/account-management/Api/Endpoints/UserEndpoints.cs +++ b/application/account-management/Api/Endpoints/UserEndpoints.cs @@ -53,5 +53,9 @@ public void MapEndpoints(IEndpointRouteBuilder routes) group.MapPut("/change-locale", async Task (ChangeLocaleCommand command, IMediator mediator) => await mediator.Send(command) ); + + group.MapGet("/timezones", async Task> ([AsParameters] GetTimeZonesQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); } } diff --git a/application/account-management/Core/AccountManagement.csproj b/application/account-management/Core/AccountManagement.csproj index 0da0b953e..71f0602ab 100644 --- a/application/account-management/Core/AccountManagement.csproj +++ b/application/account-management/Core/AccountManagement.csproj @@ -12,4 +12,8 @@ + + + + diff --git a/application/account-management/Core/Authentication/Services/AuthenticationTokenGenerator.cs b/application/account-management/Core/Authentication/Services/AuthenticationTokenGenerator.cs index 2f675acf1..8e71d6bd3 100644 --- a/application/account-management/Core/Authentication/Services/AuthenticationTokenGenerator.cs +++ b/application/account-management/Core/Authentication/Services/AuthenticationTokenGenerator.cs @@ -48,7 +48,8 @@ public string GenerateAccessToken(User user) new Claim("tenant_id", user.TenantId), new Claim("title", user.Title ?? string.Empty), new Claim("avatar_url", user.Avatar.Url ?? string.Empty), - new Claim("locale", user.Locale) + new Claim("locale", user.Locale), + new Claim("localtimezoneid", user.LocalTimeZoneId ?? string.Empty) ] ) }; diff --git a/application/account-management/Core/Database/DatabaseMigrations.cs b/application/account-management/Core/Database/DatabaseMigrations/20241007_InitialMigration.cs similarity index 97% rename from application/account-management/Core/Database/DatabaseMigrations.cs rename to application/account-management/Core/Database/DatabaseMigrations/20241007_InitialMigration.cs index 901353552..cfa417124 100644 --- a/application/account-management/Core/Database/DatabaseMigrations.cs +++ b/application/account-management/Core/Database/DatabaseMigrations/20241007_InitialMigration.cs @@ -1,11 +1,11 @@ using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; -namespace PlatformPlatform.AccountManagement.Database; +namespace PlatformPlatform.AccountManagement.Database.DatabaseMigrations; [DbContext(typeof(AccountManagementDbContext))] [Migration("20241007_Initial")] -public sealed class DatabaseMigrations : Migration +public sealed class InitialMigration : Migration { protected override void Up(MigrationBuilder migrationBuilder) { diff --git a/application/account-management/Core/Database/DatabaseMigrations/20241102_AddUserLocalTimeZoneId.cs b/application/account-management/Core/Database/DatabaseMigrations/20241102_AddUserLocalTimeZoneId.cs new file mode 100644 index 000000000..570f67416 --- /dev/null +++ b/application/account-management/Core/Database/DatabaseMigrations/20241102_AddUserLocalTimeZoneId.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace PlatformPlatform.AccountManagement.Database.DatabaseMigrations; + +[DbContext(typeof(AccountManagementDbContext))] +[Migration("20241102_AddUserLocalTimeZoneId")] +public sealed class AddUserLocalTimeZoneId : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn("LocalTimeZoneId", "Users", "nvarchar(50)", nullable: true); + } +} diff --git a/application/account-management/Core/Users/Commands/UpdateUser.cs b/application/account-management/Core/Users/Commands/UpdateUser.cs index 831d1c146..25757f0b5 100644 --- a/application/account-management/Core/Users/Commands/UpdateUser.cs +++ b/application/account-management/Core/Users/Commands/UpdateUser.cs @@ -25,6 +25,8 @@ public sealed record UpdateUserCommand : ICommand, IRequest public required string LastName { get; init; } public required string Title { get; init; } + + public string? LocalTimeZoneId { get; init; } } public sealed class UpdateUserValidator : AbstractValidator @@ -50,6 +52,7 @@ public async Task Handle(UpdateUserCommand command, CancellationToken ca user.UpdateEmail(command.Email); user.Update(command.FirstName, command.LastName, command.Title); + user.ChangeLocalTimeZoneId(command.LocalTimeZoneId); userRepository.Update(user); events.CollectEvent(new UserUpdated()); diff --git a/application/account-management/Core/Users/Domain/User.cs b/application/account-management/Core/Users/Domain/User.cs index 82fa07375..933d24237 100644 --- a/application/account-management/Core/Users/Domain/User.cs +++ b/application/account-management/Core/Users/Domain/User.cs @@ -35,6 +35,8 @@ public string Email public string Locale { get; private set; } = string.Empty; + public string? LocalTimeZoneId { get; private set; } + public TenantId TenantId { get; } public static User Create(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? gravatarUrl) @@ -79,6 +81,11 @@ public void ChangeLocale(string locale) { Locale = locale; } + + public void ChangeLocalTimeZoneId(string? timeZoneId) + { + LocalTimeZoneId = timeZoneId; + } } public sealed record Avatar(string? Url = null, int Version = 0, bool IsGravatar = false); diff --git a/application/account-management/Core/Users/Queries/GetTimezones.cs b/application/account-management/Core/Users/Queries/GetTimezones.cs new file mode 100644 index 000000000..3e367da40 --- /dev/null +++ b/application/account-management/Core/Users/Queries/GetTimezones.cs @@ -0,0 +1,25 @@ +using JetBrains.Annotations; +using PlatformPlatform.SharedKernel.Cqrs; +using TimeZoneConverter; + +namespace PlatformPlatform.AccountManagement.Users.Queries; + +[PublicAPI] +public sealed record GetTimeZonesQuery : IRequest>; + +[PublicAPI] +public sealed record GetTimeZoneDto(TimeZoneDto[] TimeZones); + +[PublicAPI] +public sealed record TimeZoneDto(string Id, string DisplayName); + +public sealed class GetTimeZonesHandler : IRequestHandler> +{ + public async Task> Handle(GetTimeZonesQuery request, CancellationToken cancellationToken) + { + var knownTimeZones = TZConvert.KnownWindowsTimeZoneIds.Select(TZConvert.GetTimeZoneInfo); + var timeZones = knownTimeZones.OrderBy(t => t.BaseUtcOffset).Select(t => new TimeZoneDto(t.Id, t.DisplayName)).ToArray(); + + return await Task.FromResult(new GetTimeZoneDto(timeZones)); + } +} diff --git a/application/account-management/Core/Users/Queries/GetUser.cs b/application/account-management/Core/Users/Queries/GetUser.cs index e7fc605dc..2e80235cf 100644 --- a/application/account-management/Core/Users/Queries/GetUser.cs +++ b/application/account-management/Core/Users/Queries/GetUser.cs @@ -27,7 +27,8 @@ public sealed record UserResponseDto( string FirstName, string LastName, string Title, - string? AvatarUrl + string? AvatarUrl, + string? LocalTimeZoneId ); public sealed class GetUserHandler(IUserRepository userRepository) diff --git a/application/account-management/Core/Users/Queries/GetUserByEmailQuery.cs b/application/account-management/Core/Users/Queries/GetUserByEmailQuery.cs index 340cf62b7..80d08da26 100644 --- a/application/account-management/Core/Users/Queries/GetUserByEmailQuery.cs +++ b/application/account-management/Core/Users/Queries/GetUserByEmailQuery.cs @@ -20,7 +20,8 @@ public sealed record UserByEmailResponseDto( string LastName, string Title, bool EmailConfirmed, - string? AvatarUrl + string? AvatarUrl, + string? LocalTimeZoneId ); public sealed class GetUserByEmailHandler(IUserRepository userRepository) diff --git a/application/account-management/Tests/Users/GetUserTests.cs b/application/account-management/Tests/Users/GetUserTests.cs index 34a3cc558..b14dbe6d9 100644 --- a/application/account-management/Tests/Users/GetUserTests.cs +++ b/application/account-management/Tests/Users/GetUserTests.cs @@ -38,6 +38,7 @@ public async Task GetUser_WhenUserExists_ShouldReturnUserWithValidContract() 'role': {'type': 'string', 'minLength': 1, 'maxLength': 20}, 'emailConfirmed': {'type': 'boolean'}, 'avatarUrl': {'type': ['null', 'string'], 'maxLength': 100}, + 'localTimeZoneId': {'type': ['null', 'string'], 'maxLength': 50}, }, 'required': ['id', 'createdAt', 'modifiedAt', 'email', 'role'], 'additionalProperties': false diff --git a/application/account-management/Tests/Users/UpdateUserTests.cs b/application/account-management/Tests/Users/UpdateUserTests.cs index 4b53c9020..0be286b00 100644 --- a/application/account-management/Tests/Users/UpdateUserTests.cs +++ b/application/account-management/Tests/Users/UpdateUserTests.cs @@ -21,7 +21,8 @@ public async Task UpdateUser_WhenValid_ShouldUpdateUser() Email = Faker.Internet.Email(), FirstName = Faker.Name.FirstName(), LastName = Faker.Name.LastName(), - Title = Faker.Name.JobTitle() + Title = Faker.Name.JobTitle(), + LocalTimeZoneId = Faker.Random.String(31) }; // Act diff --git a/application/account-management/WebApp/shared/components/userModals/UserProfileModal.tsx b/application/account-management/WebApp/shared/components/userModals/UserProfileModal.tsx index 8ad416822..718ea8dc9 100644 --- a/application/account-management/WebApp/shared/components/userModals/UserProfileModal.tsx +++ b/application/account-management/WebApp/shared/components/userModals/UserProfileModal.tsx @@ -7,6 +7,7 @@ import { Dialog } from "@repo/ui/components/Dialog"; import { FormErrorMessage } from "@repo/ui/components/FormErrorMessage"; import { Modal } from "@repo/ui/components/Modal"; import { TextField } from "@repo/ui/components/TextField"; +import { ComboBox, ComboBoxItem } from "@repo/ui/components/ComboBox"; import type { Schemas } from "@/shared/lib/api/client"; import { api } from "@/shared/lib/api/client"; import { t, Trans } from "@lingui/macro"; @@ -19,6 +20,7 @@ type ProfileModalProps = { export default function UserProfileModal({ isOpen, onOpenChange, userId }: Readonly) { const [data, setData] = useState(null); + const [timeZones, setTimeZones] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [file, setFile] = useState(null); @@ -38,11 +40,14 @@ export default function UserProfileModal({ isOpen, onOpenChange, userId }: Reado if (isOpen) { setLoading(true); setData(null); + setTimeZones(null); setError(null); try { const response = await api.get("/api/account-management/users/{id}", { params: { path: { id: userId } } }); setData(response); + const timeZonesResponse = await api.get("/api/account-management/users/timezones"); + setTimeZones(timeZonesResponse); } catch (error) { // biome-ignore lint/suspicious/noExplicitAny: We don't know the type at this point setError(error as any); @@ -136,6 +141,20 @@ export default function UserProfileModal({ isOpen, onOpenChange, userId }: Reado defaultValue={data?.title} placeholder={t`E.g., Marketing Manager`} /> + { + setData({ ...data, localTimeZoneId: selected as string }); + }} + > + {timeZones?.timeZones.map((timeZone) => ( + + {timeZone.displayName} + + ))} + diff --git a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json index 14689dd15..90164b04e 100644 --- a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json +++ b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json @@ -629,6 +629,26 @@ } } }, + "/api/account-management/users/timezones": { + "get": { + "tags": [ + "Users" + ], + "operationId": "GetApiAccountManagementUsersTimezones", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTimeZoneDto" + } + } + } + } + } + } + }, "/internal-api/account-management/users/{id}": { "get": { "tags": [ @@ -1072,6 +1092,10 @@ "avatarUrl": { "type": "string", "nullable": true + }, + "localTimeZoneId": { + "type": "string", + "nullable": true } } }, @@ -1135,6 +1159,10 @@ }, "title": { "type": "string" + }, + "localTimeZoneId": { + "type": "string", + "nullable": true } } }, @@ -1165,6 +1193,30 @@ } } }, + "GetTimeZoneDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "timeZones": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimeZoneDto" + } + } + } + }, + "TimeZoneDto": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "displayName": { + "type": "string" + } + } + }, "UserByEmailResponseDto": { "type": "object", "additionalProperties": false, @@ -1205,6 +1257,10 @@ "avatarUrl": { "type": "string", "nullable": true + }, + "localTimeZoneId": { + "type": "string", + "nullable": true } } } diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index b288840e6..e2ea0dfd3 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -244,6 +244,9 @@ msgstr "Bekræftelseskoden, du forsøger at bruge, er udløbet for tilmeldings-I msgid "This is the region where your data is stored" msgstr "Dette er den region, hvor dine data er lagret" +msgid "TimeZone" +msgstr "Tidszone" + msgid "Title" msgstr "Titel" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index 19a2ba6dd..40a9bca64 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -244,6 +244,9 @@ msgstr "The verification code you are trying to use has expired for Signup ID: { msgid "This is the region where your data is stored" msgstr "This is the region where your data is stored" +msgid "TimeZone" +msgstr "Time zone" + msgid "Title" msgstr "Title" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index 6e23d96ea..da6509ccb 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -244,6 +244,9 @@ msgstr "De verificatiecode die u probeert te gebruiken is verlopen voor Registra msgid "This is the region where your data is stored" msgstr "Dit is de regio waar je gegevens zijn opgeslagen" +msgid "TimeZone" +msgstr "Tijdzone" + msgid "Title" msgstr "Titel" diff --git a/application/calendar-assistant/Core/AI/Planner/ActionPlanner.cs b/application/calendar-assistant/Core/AI/Planner/ActionPlanner.cs index 191e582b0..0b125deee 100644 --- a/application/calendar-assistant/Core/AI/Planner/ActionPlanner.cs +++ b/application/calendar-assistant/Core/AI/Planner/ActionPlanner.cs @@ -3,6 +3,7 @@ using Moment42.CalendarAssistant.AI.Contracts; using Moment42.CalendarAssistant.AI.Gpt; using Moment42.CalendarAssistant.Application.Conversations.Domain; +using Moment42.CalendarAssistant.Extensions; using OpenAI.Chat; namespace Moment42.CalendarAssistant.AI.Planner; @@ -60,30 +61,30 @@ private SystemChatMessage GetSystemPrompt(ConversationState conversationState, I string[] formalityLevels = ["Very Formal", "Formal", "Neutral", "Casual", "Very Casual"]; return new SystemChatMessage( - $$$$""" - You are a Calendar AI Assistant. Assist only with calendar tasks by creating a plan using available actions. - Rules: - - Return a minified JSON with thoughts, reasoning, plannedActions, and possibleActions. - - Add actions to possibleActions only if not in plannedActions. - - Do not make any assumptions. Follow user instructions and information from available actions. - - Try to use actions to determine unknown information. - - Format response as: {"plannedActions":[{"uniqueId":"","name":"","parameters":{"":""}}],"possibleActions":[{"name":"","parameters":{"":""}}],"thoughts":{"thought":"","reasoning":""}} - - Use UTC and ISO 8601 for dates and times. - - Dates/times mentioned by user are local unless stated otherwise. - - Use "{{{{SayActionName}}}}" action if unsure or missing parameters, placing it in plannedActions. Make messages personalized, {{{{formalityLevels[Random.Next(formalityLevels.Length)]}}}} formality. - Metadata: - - UserId: {{{{executionContext.UserInfo.UserId}}}} - - User TenantId: {{{{executionContext.UserInfo.TenantId}}}} - - User DisplayName: {{{{executionContext.UserInfo.FirstName}}}} {{{{executionContext.UserInfo.LastName}}}} - - UTC Timestamp: {{{{DateTimeOffset.UtcNow:yyyy-MM-ddTHH:mm:ss}}}} - - Local Timestamp: {{{{executionContext.UserInfo.LocalTimestamp?.ToString("yyyy-MM-ddTHH:mm:ss")}}}} - - Local Time Zone: {{{{executionContext.UserInfo.LocalTimezone}}}} - - Available Actions: - - {{{{_availableActionsSchema}}}} - {{{{possibleActions}}}} - {{{{executedActions}}}} - {{{{chatHistory}}}} - """ + $$$""" + You are a Calendar AI Assistant. Assist only with calendar tasks by creating a plan using available actions. + Rules: + - Return a minified JSON with thoughts, reasoning, plannedActions, and possibleActions. + - Add actions to possibleActions only if not in plannedActions. + - Do not make any assumptions. Follow user instructions and information from available actions. + - Try to use actions to determine unknown information. + - Format response as: {"plannedActions":[{"uniqueId":"","name":"","parameters":{"":""}}],"possibleActions":[{"name":"","parameters":{"":""}}],"thoughts":{"thought":"","reasoning":""}} + - Use UTC and ISO 8601 for dates and times. + - Dates/times mentioned by user are local unless stated otherwise. + - Use "{{{SayActionName}}}" action if unsure or missing parameters, placing it in plannedActions. Make messages personalized, {{{formalityLevels[Random.Next(formalityLevels.Length)]}}} formality. + Metadata: + - UserId: {{{executionContext.UserInfo.UserId}}} + - User TenantId: {{{executionContext.UserInfo.TenantId}}} + - User DisplayName: {{{executionContext.UserInfo.FirstName}}} {{{executionContext.UserInfo.LastName}}} + - UTC Timestamp: {{{DateTimeOffset.UtcNow:yyyy-MM-ddTHH:mm:ss}}} + - Local Timestamp: {{{(executionContext.UserInfo.LocalTimeZoneId is null ? DateTimeOffset.UtcNow : DateTimeOffset.UtcNow.ToLocalTime(executionContext.UserInfo.LocalTimeZoneId)):yyyy-MM-ddTHH:mm:ss}}} + - Local Time Zone: {{{executionContext.UserInfo.LocalTimeZoneId}}} + - Available Actions: + - {{{_availableActionsSchema}}} + {{{possibleActions}}} + {{{executedActions}}} + {{{chatHistory}}} + """ ); } diff --git a/application/calendar-assistant/Core/Application/Users/Commands/CreateUser.cs b/application/calendar-assistant/Core/Application/Users/Commands/CreateUser.cs index 4ed7a78a8..8c310600f 100644 --- a/application/calendar-assistant/Core/Application/Users/Commands/CreateUser.cs +++ b/application/calendar-assistant/Core/Application/Users/Commands/CreateUser.cs @@ -1,7 +1,6 @@ using System.Net; -using System.Net.Http.Json; -using JetBrains.Annotations; using Moment42.CalendarAssistant.Clients.AccountManagement; +using Moment42.CalendarAssistant.Extensions; using NUlid; using PlatformPlatform.SharedKernel.Cqrs; using PlatformPlatform.SharedKernel.Domain; @@ -12,66 +11,43 @@ public sealed record CreateUser( string Email, string FirstName, string LastName, - string Title + string Title, + string? LocalTimeZoneId ) : IRequest>, ICommand; -public sealed class CreateUserHandler(IHttpClientFactory httpClientFactory) : IRequestHandler> +public sealed class CreateUserHandler(IAccountManagementClient accountManagementClient) : IRequestHandler> { public async Task> Handle(CreateUser command, CancellationToken cancellationToken) { - var createdUser = false; - var accountManagementHttpClient = httpClientFactory.CreateClient("AccountManagement"); - var getUserByEmailResponse = await accountManagementHttpClient.SendAsync( - new HttpRequestMessage(HttpMethod.Get, $"{AccountManagementEndpoints.GetUserByEmailEndpoint}/{WebUtility.UrlEncode(command.Email)}"), - cancellationToken - ); - switch (getUserByEmailResponse.StatusCode) + bool createdUser; + try { - case HttpStatusCode.NotFound: - var createTenantRequest = new HttpRequestMessage(HttpMethod.Post, AccountManagementEndpoints.CreateTenantEndpoint) - { - Content = JsonContent.Create(new { id = new { value = Ulid.NewUlid().ToString().ToLower() }, ownerEmail = command.Email, emailConfirmed = true }) - }; - - var createTenantResponse = await accountManagementHttpClient.SendAsync(createTenantRequest, cancellationToken); - if (!createTenantResponse.IsSuccessStatusCode) - { - return Result.BadRequest("Unable to create tenant."); - } - - getUserByEmailResponse = await accountManagementHttpClient.SendAsync( - new HttpRequestMessage(HttpMethod.Get, $"{AccountManagementEndpoints.GetUserByEmailEndpoint}/{WebUtility.UrlEncode(command.Email)}"), - cancellationToken - ); - createdUser = true; - break; - case HttpStatusCode.OK: - break; - default: - return Result.BadRequest("Unable to get user by email."); + var user = await accountManagementClient.GetUserByEmail(command.Email, cancellationToken); + return Result.Success(new UserId(user.Id)); } - - var userResponse = await getUserByEmailResponse.Content.ReadFromJsonAsync(cancellationToken); - if (userResponse is null) + catch (AccountManagementClient.AccountManagementClientException accountManagementClientException) when (accountManagementClientException.HttpStatusCode == HttpStatusCode.NotFound) { - return Result.BadRequest("Unable to get user by email."); + await accountManagementClient.CreateTenant(Ulid.NewUlid().ToString().ToLower(), command.Email); + createdUser = true; } if (!createdUser) { - return Result.Success(new UserId(userResponse.Id)); + return Result.BadRequest($"Unable to create user for email {command.Email.MaskEmail()}."); } - var updateUserRequest = new HttpRequestMessage(HttpMethod.Put, $"{AccountManagementEndpoints.UserEndpoint}/{userResponse.Id}") + try { - Content = JsonContent.Create(new { email = command.Email, firstName = command.FirstName, lastName = command.LastName, title = command.Title }) - }; - await accountManagementHttpClient.SendAsync(updateUserRequest, cancellationToken); - - return Result.Success(new UserId(userResponse.Id)); + var user = await accountManagementClient.GetUserByEmail(command.Email, cancellationToken); + await accountManagementClient.UpdateUser(user.Id, command.Email, command.FirstName, command.LastName, command.Title, + command.LocalTimeZoneId, cancellationToken + ); + return Result.Success(new UserId(user.Id)); + } + catch (AccountManagementClient.AccountManagementClientException) + { + return Result.BadRequest($"Unable to create user for email {command.Email.MaskEmail()}."); + } } - - [PublicAPI] - private record UserResponse(string Id, string TenantId); } diff --git a/application/calendar-assistant/Core/Application/Users/Queries/GetUserInfoByEmail.cs b/application/calendar-assistant/Core/Application/Users/Queries/GetUserInfoByEmail.cs index 5c47a8713..160f6f263 100644 --- a/application/calendar-assistant/Core/Application/Users/Queries/GetUserInfoByEmail.cs +++ b/application/calendar-assistant/Core/Application/Users/Queries/GetUserInfoByEmail.cs @@ -1,6 +1,5 @@ -using System.Net; -using System.Net.Http.Json; using JetBrains.Annotations; +using Mapster; using Moment42.CalendarAssistant.Clients.AccountManagement; using PlatformPlatform.SharedKernel.Authentication; using PlatformPlatform.SharedKernel.Cqrs; @@ -10,48 +9,20 @@ namespace Moment42.CalendarAssistant.Application.Users.Queries; [PublicAPI] public sealed record GetUserInfoByEmail(string Email) : IRequest>; -public sealed class GetUserInfoByEmailHandler(IHttpClientFactory httpClientFactory) : IRequestHandler> +public sealed class GetUserInfoByEmailHandler(IAccountManagementClient accountManagementClient) : IRequestHandler> { public async Task> Handle(GetUserInfoByEmail request, CancellationToken cancellationToken) { - var accountManagementHttpClient = httpClientFactory.CreateClient("AccountManagement"); - var getUserResponse = await accountManagementHttpClient.SendAsync( - new HttpRequestMessage(HttpMethod.Get, $"{AccountManagementEndpoints.GetUserByEmailEndpoint}/{WebUtility.UrlEncode(request.Email)}"), - cancellationToken - ); - if (getUserResponse.StatusCode != HttpStatusCode.OK) + try { - return Result.NotFound($"User with Email {request.Email} not found."); + var user = await accountManagementClient.GetUserByEmail(request.Email, cancellationToken); + var typeAdapterConfig = new TypeAdapterConfig(); + typeAdapterConfig.ForType().Map(dest => dest.UserId, src => src.Id); + return Result.Success(user.Adapt(typeAdapterConfig)); } - - var userResponse = await getUserResponse.Content.ReadFromJsonAsync(cancellationToken); - if (userResponse is not null) + catch (AccountManagementClient.AccountManagementClientException accountManagementClientException) { - return Result.Success(new UserInfo - { - UserId = userResponse.Id, - TenantId = userResponse.TenantId, - Email = userResponse.Email, - FirstName = userResponse.FirstName, - LastName = userResponse.LastName, - Title = userResponse.Title - } - ); + return Result.NotFound(accountManagementClientException.Message); } - - return Result.NotFound($"User with Email {request.Email} not found."); } - - [PublicAPI] - private record UserResponse( - string Id, - string TenantId, - DateTimeOffset CreatedAt, - DateTimeOffset? ModifiedAt, - string Email, - string FirstName, - string LastName, - string Title, - string? AvatarUrl - ); } diff --git a/application/calendar-assistant/Core/CalendarAssistant.csproj b/application/calendar-assistant/Core/CalendarAssistant.csproj index 5fd537978..080dd51cc 100644 --- a/application/calendar-assistant/Core/CalendarAssistant.csproj +++ b/application/calendar-assistant/Core/CalendarAssistant.csproj @@ -18,6 +18,7 @@ + diff --git a/application/calendar-assistant/Core/Clients/AccountManagement/AccountManagementClient.cs b/application/calendar-assistant/Core/Clients/AccountManagement/AccountManagementClient.cs index c7fb834b7..aea46d3f5 100644 --- a/application/calendar-assistant/Core/Clients/AccountManagement/AccountManagementClient.cs +++ b/application/calendar-assistant/Core/Clients/AccountManagement/AccountManagementClient.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using JetBrains.Annotations; +using Moment42.CalendarAssistant.Extensions; using PlatformPlatform.SharedKernel.Domain; namespace Moment42.CalendarAssistant.Clients.AccountManagement; @@ -8,6 +9,12 @@ namespace Moment42.CalendarAssistant.Clients.AccountManagement; public interface IAccountManagementClient { Task GetUser(UserId userId, CancellationToken cancellationToken); + + Task GetUserByEmail(string email, CancellationToken cancellationToken); + + Task CreateTenant(string id, string commandEmail); + + Task UpdateUser(string id, string email, string firstName, string lastName, string title, string? localTimeZoneId, CancellationToken cancellationToken); } public sealed class AccountManagementClient(IHttpClientFactory httpClientFactory) : IAccountManagementClient @@ -22,18 +29,83 @@ public async Task GetUser(UserId userId, Cancella if (getUserResponse.StatusCode != HttpStatusCode.OK) { - throw new AccountManagementClientException($"User with UserId {userId} not found. Status code {getUserResponse.StatusCode}."); + throw new AccountManagementClientException($"User with UserId {userId} not found.", getUserResponse.StatusCode); } return await getUserResponse.Content.ReadFromJsonAsync(cancellationToken) - ?? throw new AccountManagementClientException($"Failed to parse response from user endpoint. UserId {userId}."); + ?? throw new AccountManagementClientException($"Failed to parse response from user endpoint. UserId {userId}.", getUserResponse.StatusCode); } - private sealed class AccountManagementClientException(string message) : Exception(message); + public async Task GetUserByEmail(string email, CancellationToken cancellationToken) + { + var accountManagementHttpClient = httpClientFactory.CreateClient("AccountManagement"); + var getUserResponse = await accountManagementHttpClient.SendAsync( + new HttpRequestMessage(HttpMethod.Get, $"{AccountManagementEndpoints.GetUserByEmailEndpoint}/{WebUtility.UrlEncode(email)}"), + cancellationToken + ); + + if (getUserResponse.StatusCode != HttpStatusCode.OK) + { + throw new AccountManagementClientException($"User with email {email.MaskEmail()} not found.", getUserResponse.StatusCode); + } + + return await getUserResponse.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new AccountManagementClientException($"Failed to parse response from user endpoint. Email {email.MaskEmail()}.", getUserResponse.StatusCode); + } + + public async Task CreateTenant(string id, string commandEmail) + { + var accountManagementHttpClient = httpClientFactory.CreateClient("AccountManagement"); + var createTenantRequest = new HttpRequestMessage(HttpMethod.Post, AccountManagementEndpoints.CreateTenantEndpoint) + { + Content = JsonContent.Create(new { id = new { value = id }, ownerEmail = commandEmail, emailConfirmed = true }) + }; + + var createTenantResponse = await accountManagementHttpClient.SendAsync(createTenantRequest); + if (!createTenantResponse.IsSuccessStatusCode) + { + throw new AccountManagementClientException("Unable to create tenant.", createTenantResponse.StatusCode); + } + } + + public async Task UpdateUser( + string id, + string email, + string firstName, + string lastName, + string title, + string? localTimeZoneId, + CancellationToken cancellationToken) + { + var accountManagementHttpClient = httpClientFactory.CreateClient("AccountManagement"); + var updateUserRequest = new HttpRequestMessage(HttpMethod.Put, $"{AccountManagementEndpoints.UserEndpoint}/{id}") + { + Content = JsonContent.Create(new { email, firstName, lastName, title, localTimeZoneId }) + }; + var updateUserResponse = await accountManagementHttpClient.SendAsync(updateUserRequest, cancellationToken); + if (updateUserResponse.StatusCode != HttpStatusCode.OK) + { + throw new AccountManagementClientException($"Unable to update user {id}.", updateUserResponse.StatusCode); + } + } + + public sealed class AccountManagementClientException(string message, HttpStatusCode httpStatusCode) : Exception(message) + { + public HttpStatusCode HttpStatusCode { get; } = httpStatusCode; + } } [PublicAPI] public sealed record AccountManagementUserResponse( string Id, - string TenantId + string TenantId, + DateTimeOffset CreatedAt, + DateTimeOffset? ModifiedAt, + string Email, + string FirstName, + string LastName, + string Title, + bool EmailConfirmed, + string? AvatarUrl, + string? LocalTimeZoneId ); diff --git a/application/calendar-assistant/Core/Clients/Calendar/Microsoft365CalendarClient.cs b/application/calendar-assistant/Core/Clients/Calendar/Microsoft365CalendarClient.cs index c94e60c90..38e4a22b6 100644 --- a/application/calendar-assistant/Core/Clients/Calendar/Microsoft365CalendarClient.cs +++ b/application/calendar-assistant/Core/Clients/Calendar/Microsoft365CalendarClient.cs @@ -7,6 +7,7 @@ using Moment42.CalendarAssistant.Application.CalendarConnections.Domain; using Moment42.CalendarAssistant.Authentication; using Moment42.CalendarAssistant.Clients.MicrosoftGraph; +using Moment42.CalendarAssistant.Extensions; using PlatformPlatform.SharedKernel.Domain; using PlatformPlatform.SharedKernel.SinglePageApp; @@ -30,17 +31,17 @@ public async Task CreateCalendarEntry(CalendarEntryDetails cale var endTime = calendarEntry.EndTime; var eventStart = new DateTimeTimeZone { DateTime = startTime.ToString("yyyy-MM-ddTHH:mm:ss"), TimeZone = "UTC" }; var eventEnd = new DateTimeTimeZone { DateTime = endTime.ToString("yyyy-MM-ddTHH:mm:ss"), TimeZone = "UTC" }; - if (executionContext.UserInfo.LocalTimestamp is not null && executionContext.UserInfo.LocalTimezone is not null) + if (executionContext.UserInfo.LocalTimeZoneId is not null) { eventStart = new DateTimeTimeZone { - DateTime = startTime.ToOffset(executionContext.UserInfo.LocalTimestamp.Value.Offset).ToString("yyyy-MM-ddTHH:mm:ss"), - TimeZone = executionContext.UserInfo.LocalTimezone + DateTime = startTime.ToLocalTime(executionContext.UserInfo.LocalTimeZoneId).ToString("yyyy-MM-ddTHH:mm:ss"), + TimeZone = executionContext.UserInfo.LocalTimeZoneId }; eventEnd = new DateTimeTimeZone { - DateTime = endTime.ToOffset(executionContext.UserInfo.LocalTimestamp.Value.Offset).ToString("yyyy-MM-ddTHH:mm:ss"), - TimeZone = executionContext.UserInfo.LocalTimezone + DateTime = endTime.ToLocalTime(executionContext.UserInfo.LocalTimeZoneId).ToString("yyyy-MM-ddTHH:mm:ss"), + TimeZone = executionContext.UserInfo.LocalTimeZoneId }; } diff --git a/application/calendar-assistant/Core/Extensions/DateTimeOffsetExtensions.cs b/application/calendar-assistant/Core/Extensions/DateTimeOffsetExtensions.cs new file mode 100644 index 000000000..870453da6 --- /dev/null +++ b/application/calendar-assistant/Core/Extensions/DateTimeOffsetExtensions.cs @@ -0,0 +1,14 @@ +using TimeZoneConverter; + +namespace Moment42.CalendarAssistant.Extensions; + +public static class DateTimeOffsetExtensions +{ + public static DateTimeOffset ToLocalTime(this DateTimeOffset utcNow, string timeZoneId) + { + var timeZoneInfo = TZConvert.GetTimeZoneInfo(timeZoneId); + + var utcOffset = timeZoneInfo.GetUtcOffset(utcNow); + return utcNow.Add(utcOffset); + } +} diff --git a/application/calendar-assistant/Core/Extensions/EmailExtensions.cs b/application/calendar-assistant/Core/Extensions/EmailExtensions.cs new file mode 100644 index 000000000..0e6d04b40 --- /dev/null +++ b/application/calendar-assistant/Core/Extensions/EmailExtensions.cs @@ -0,0 +1,27 @@ +namespace Moment42.CalendarAssistant.Extensions; + +public static class EmailExtensions +{ + public static string MaskEmail(this string email) + { + if (string.IsNullOrEmpty(email) || !email.Contains('@')) + { + return email; + } + + var emailParts = email.Split('@'); + var username = emailParts[0]; + var domain = emailParts[1]; + + var maskedUsername = username.Length > 1 + ? username[0] + new string('*', username.Length - 1) + : username; + + var domainParts = domain.Split('.'); + var maskedDomain = domainParts[0].Length > 1 + ? domainParts[0][0] + new string('*', domainParts[0].Length - 1) + : domainParts[0]; + + return $"{maskedUsername}@{maskedDomain}.{domainParts[1]}"; + } +} diff --git a/application/calendar-assistant/MicrosoftTeams/Authentication/AuthenticationManager.cs b/application/calendar-assistant/MicrosoftTeams/Authentication/AuthenticationManager.cs index d2634abfa..119752f4c 100644 --- a/application/calendar-assistant/MicrosoftTeams/Authentication/AuthenticationManager.cs +++ b/application/calendar-assistant/MicrosoftTeams/Authentication/AuthenticationManager.cs @@ -6,6 +6,7 @@ using Moment42.CalendarAssistant.Application.Users.Queries; using Moment42.CalendarAssistant.Clients.MicrosoftGraph; using PlatformPlatform.SharedKernel.Domain; +using TimeZoneConverter; namespace Moment42.CalendarAssistant.MicrosoftTeams.Authentication; @@ -54,7 +55,10 @@ public async Task SignUserIn(ITurnContext turnContext, Cancellat var getUserInfoResult = await mediator.Send(new GetUserInfoByEmail(entraIdUser.Mail), cancellationToken); if (getUserInfoResult.Value is null) { - var createUserResult = await mediator.Send(new CreateUser(entraIdUser.Mail, entraIdUser.GivenName ?? string.Empty, entraIdUser.Surname ?? string.Empty, entraIdUser.JobTitle ?? string.Empty), cancellationToken); + var createUserResult = await mediator.Send(new CreateUser(entraIdUser.Mail, entraIdUser.GivenName ?? string.Empty, + entraIdUser.Surname ?? string.Empty, entraIdUser.JobTitle ?? string.Empty, TZConvert.IanaToWindows(turnContext.Activity.LocalTimezone) + ), cancellationToken + ); if (createUserResult.Value is not null) { getUserInfoResult = await mediator.Send(new GetUserInfoByEmail(entraIdUser.Mail), cancellationToken); diff --git a/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs b/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs index 1e4ec169a..82867b3fc 100644 --- a/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs +++ b/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs @@ -24,9 +24,7 @@ public class UserInfo public string? Locale { get; init; } - public DateTimeOffset? LocalTimestamp { get; init; } - - public string? LocalTimezone { get; init; } + public string? LocalTimeZoneId { get; init; } public string? UserId { get; init; } @@ -66,7 +64,8 @@ public static UserInfo Create(ClaimsPrincipal? user, string? browserLocale) LastName = user.FindFirstValue(ClaimTypes.Surname), Title = user.FindFirstValue("title"), AvatarUrl = user.FindFirstValue("avatar_url"), - Locale = GetValidLocale(user.FindFirstValue("locale")) + Locale = GetValidLocale(user.FindFirstValue("locale")), + LocalTimeZoneId = user.FindFirstValue("localtimezoneid") }; }