Skip to content

Commit

Permalink
Add configurable local timezone to user profile for accurate calendar…
Browse files Browse the repository at this point in the history
… 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
  • Loading branch information
BlueBasher authored Nov 5, 2024
1 parent 7136d51 commit f2159b7
Show file tree
Hide file tree
Showing 28 changed files with 342 additions and 129 deletions.
1 change: 1 addition & 0 deletions application/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="1.2.27" />
<PackageVersion Include="Scrutor" Version="5.0.1" />
<PackageVersion Include="TimeZoneConverter" Version="6.1.0" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.2.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
Expand Down
4 changes: 4 additions & 0 deletions application/account-management/Api/Endpoints/UserEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,9 @@ public void MapEndpoints(IEndpointRouteBuilder routes)
group.MapPut("/change-locale", async Task<ApiResult> (ChangeLocaleCommand command, IMediator mediator)
=> await mediator.Send(command)
);

group.MapGet("/timezones", async Task<ApiResult<GetTimeZoneDto>> ([AsParameters] GetTimeZonesQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<GetTimeZoneDto>();
}
}
4 changes: 4 additions & 0 deletions application/account-management/Core/AccountManagement.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@
<ProjectReference Include="..\..\shared-kernel\SharedKernel\SharedKernel.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="TimeZoneConverter" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -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)
]
)
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>("LocalTimeZoneId", "Users", "nvarchar(50)", nullable: true);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ public sealed record UpdateUserCommand : ICommand, IRequest<Result>
public required string LastName { get; init; }

public required string Title { get; init; }

public string? LocalTimeZoneId { get; init; }
}

public sealed class UpdateUserValidator : AbstractValidator<UpdateUserCommand>
Expand All @@ -50,6 +52,7 @@ public async Task<Result> 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());
Expand Down
7 changes: 7 additions & 0 deletions application/account-management/Core/Users/Domain/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
25 changes: 25 additions & 0 deletions application/account-management/Core/Users/Queries/GetTimezones.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using JetBrains.Annotations;
using PlatformPlatform.SharedKernel.Cqrs;
using TimeZoneConverter;

namespace PlatformPlatform.AccountManagement.Users.Queries;

[PublicAPI]
public sealed record GetTimeZonesQuery : IRequest<Result<GetTimeZoneDto>>;

[PublicAPI]
public sealed record GetTimeZoneDto(TimeZoneDto[] TimeZones);

[PublicAPI]
public sealed record TimeZoneDto(string Id, string DisplayName);

public sealed class GetTimeZonesHandler : IRequestHandler<GetTimeZonesQuery, Result<GetTimeZoneDto>>
{
public async Task<Result<GetTimeZoneDto>> 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));
}
}
3 changes: 2 additions & 1 deletion application/account-management/Core/Users/Queries/GetUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions application/account-management/Tests/Users/GetUserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -19,6 +20,7 @@ type ProfileModalProps = {

export default function UserProfileModal({ isOpen, onOpenChange, userId }: Readonly<ProfileModalProps>) {
const [data, setData] = useState<Schemas["UserResponseDto"] | null>(null);
const [timeZones, setTimeZones] = useState<Schemas["GetTimeZoneDto"] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [file, setFile] = useState<string | null>(null);
Expand All @@ -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);
Expand Down Expand Up @@ -136,6 +141,20 @@ export default function UserProfileModal({ isOpen, onOpenChange, userId }: Reado
defaultValue={data?.title}
placeholder={t`E.g., Marketing Manager`}
/>
<ComboBox
name="localTimeZoneId"
label={t`TimeZone`}
selectedKey={data?.localTimeZoneId}
onSelectionChange={(selected) => {
setData({ ...data, localTimeZoneId: selected as string });
}}
>
{timeZones?.timeZones.map((timeZone) => (
<ComboBoxItem key={timeZone.id} id={timeZone.id}>
{timeZone.displayName}
</ComboBoxItem>
))}
</ComboBox>

<FormErrorMessage title={title} message={message} />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -1072,6 +1092,10 @@
"avatarUrl": {
"type": "string",
"nullable": true
},
"localTimeZoneId": {
"type": "string",
"nullable": true
}
}
},
Expand Down Expand Up @@ -1135,6 +1159,10 @@
},
"title": {
"type": "string"
},
"localTimeZoneId": {
"type": "string",
"nullable": true
}
}
},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1205,6 +1257,10 @@
"avatarUrl": {
"type": "string",
"nullable": true
},
"localTimeZoneId": {
"type": "string",
"nullable": true
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading

0 comments on commit f2159b7

Please sign in to comment.