Skip to content

Commit

Permalink
Added UserProfile API
Browse files Browse the repository at this point in the history
  • Loading branch information
jezzsantos committed Mar 29, 2024
1 parent f22dcaa commit e760b93
Show file tree
Hide file tree
Showing 68 changed files with 3,501 additions and 102 deletions.
10 changes: 5 additions & 5 deletions docs/design-principles/0130-multitenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ The software accesses the data for each user on behalf of the tenant they belong

In essence, a 'Tenant' is a loose boundary around some data and services that is shared by one or more specific users and not others.

A tenant can be scoped in a given digital product as any of these common concepts: a Country, a Company, a Business, an Organisation, a Workspace, a Project, a Party, a Team, and any other kind of group or grouping.
A tenant can be scoped in a given digital product as any of these common concepts: a Country, a Company, a Business, an Organization, a Workspace, a Project, a Party, a Team, and any other kind of group or grouping.

> Depending on the specific product, all of these concepts can be manifested as separate tenants.
Expand Down Expand Up @@ -82,7 +82,7 @@ The choice SaaStack has made is to define all identities centrally, outside any
The implication of this decision is that:

* An `EndUser` in the system is unique across all tenancies by their username (their email address). That implies that a human using the system is the same human (by email) address, no matter what tenancy they are working in at any one time. (e.g., an independent consultant collaborating with more than one company). That human can of course use several email addresses, should it be necessary (due to rules/constraints) to access any specific tenancy only with a certain email domain. Which is a common requirement in many enterprise B2B products.
* Any `EndUser` can have any number of `Memberships` to any number of tenancies (`Organisations`), which is common for many B2B and B2C products. However, they must always have at least one (see later).
* Any `EndUser` can have any number of `Memberships` to any number of tenancies (`Organizations`), which is common for many B2B and B2C products. However, they must always have at least one (see later).
* The authentication (login) process, would be branded with the branding of the the SaaS product itself, since at that point in time, it is not clear which tenancy the user wishes to access. Which is common for a lot of B2C and B2B products. Think www.slack.com, or www.miro.com or www.google.com, etc, where you login to the unbranded product, before accessing the branded tenant.
* This also means that any `Enduser` will need to have a default "home" tenancy at all times, so that they can always login to the platform and either make a choice about what context they are working in at that time, or be sent to the default tenancy they normally work in, or last worked in, etc..
* This also means that when they register on the central platform they are automatically assigned to their own "personal" tenant (`Organization`), from which they can use the product (to some degree - depending on their subscription at that time). They must always have that personal `Organization`, for the times when they lose access to any other tenancy (i.e., a consultant ends their engagement with a company, or as an employee change jobs at any organization).
Expand Down Expand Up @@ -120,9 +120,9 @@ In SaaStack, a tenant is initially modeled as an `Organization`.

> The "Organization" subdomain can be renamed to be any of these concepts, to fit the specific business model of the SaaS business: `Group`, `Company`, `Workspace`, `Project`, `Part` or `Team`, etc.
When a new ~~tenant~~ `Organisation` is created (via the API), it is created in a centralized (and untenanted) part of the system, where all `EndUser` and `Memberships` are also created. This means that `Organizations` are global across the entire product.
When a new ~~tenant~~ `Organization` is created (via the API), it is created in a centralized (and untenanted) part of the system, where all `EndUser` and `Memberships` are also created. This means that `Organizations` are global across the entire product.

The data/record about the `Organisation` is created instantly. At the same time, the record is populated with any settings pertinent to that tenant. (see Configuration section below).
The data/record about the `Organization` is created instantly. At the same time, the record is populated with any settings pertinent to that tenant. (see Configuration section below).

If any infrastructure is required to be provisioned and configured, that process (automated or manual) can be triggered by registering a listener to events from the `Organization` events (via Notifications), and by responding to the `OrganizationsDomain.Events.Created` event.

Expand Down Expand Up @@ -313,7 +313,7 @@ Consider the following workflow:
1. A new customer signs up for the platform. They register a new user, and that will create a new `Personal` organization for them to use the product. This organization will have a billing subscription that gives them some [limited] access level to the product at this time (i.e., a trial).
2. At that time, or at some future time (like when they upgrade to a paid plan), a new event (e.g., `EndUsersDomain.Events.Registered`) can be subscribed to by adding a new `IEventNotificationRegistration` in one of the subdomains.
3. This event is then raised at runtime, which triggers an application (in some subdomain) to make some API call to some cloud-based process to provision some specific infrastructure (e.g., via queue message or direct via an API call to an Azure function or AWS Lambda - there are many integration options). Let's assume that this triggers Azure to create a new SQL database in a regional data center physically closer to where this specific customer is signing up.
4. Let's assume that this cloud provisioning process takes some time to complete (perhaps several minutes), and meanwhile, the customer is starting using the product and try it out for themselves (using their `Personal` organisation, which we assume is using shared platform infrastructure at this time.
4. Let's assume that this cloud provisioning process takes some time to complete (perhaps several minutes), and meanwhile, the customer is starting using the product and try it out for themselves (using their `Personal` organization, which we assume is using shared platform infrastructure at this time.
5. When the provisioning process is completed (a few minutes later), a new message [containing some data about the provisioning process] is created and dropped on the `provisioning` queue (in Azure or AWS).
6. The `DeliverProvisioning` task is triggered, and the message is picked up off the queue and delivered to the `Ancillary` API by the Azure function or AWS Lambda.
7. The `Ancillary` API then handles the message and forwards it to the `Organization` subdomain to update the settings of the `Personal` organization that the customer is using.
Expand Down
Binary file added docs/images/EndUser-Lifecycle.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/Sources.pptx
Binary file not shown.
Binary file modified docs/images/Subdomains.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/ApiHost1/ApiHost1.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<ProjectReference Include="..\CarsInfrastructure\CarsInfrastructure.csproj" />
<ProjectReference Include="..\Infrastructure.Web.Hosting.Common\Infrastructure.Web.Hosting.Common.csproj" />
<ProjectReference Include="..\OrganizationsInfrastructure\OrganizationsInfrastructure.csproj" />
<ProjectReference Include="..\UserProfilesInfrastructure\UserProfilesInfrastructure.csproj" />
</ItemGroup>

<!-- Runs the source generator (in memory) on build -->
Expand Down
2 changes: 2 additions & 0 deletions src/ApiHost1/HostedModules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using IdentityInfrastructure;
using Infrastructure.Web.Hosting.Common;
using OrganizationsInfrastructure;
using UserProfilesInfrastructure;

namespace ApiHost1;

Expand All @@ -14,6 +15,7 @@ public static SubdomainModules Get()
{
var modules = new SubdomainModules();
modules.Register(new ApiHostModule());
modules.Register(new UserProfilesModule());
modules.Register(new EndUsersModule());
modules.Register(new OrganizationsModule());
modules.Register(new IdentityModule());
Expand Down
2 changes: 2 additions & 0 deletions src/Application.Interfaces/UsageConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public static class Properties
public const string CallId = "CallId";
public const string Component = "Component";
public const string Duration = "Duration";
public const string EmailAddress = "EmailAddress";
public const string EndPoint = "EndPoint";
public const string HttpMethod = "Method";
public const string HttpPath = "Path";
Expand Down Expand Up @@ -46,6 +47,7 @@ public static class UsageScenarios
public const string Measurement = "Measured";
public const string PersonRegistrationConfirmed = "User Registered";
public const string PersonRegistrationCreated = "User Registration Created";
public const string PersonReRegistered = "User Registration ReAttempted";
public const string UserExtendedLogin = "User Extended Login";
public const string UserLogin = "User Login";
public const string UserLogout = "User Logout";
Expand Down
2 changes: 1 addition & 1 deletion src/Application.Resources.Shared/EndUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public enum EndUserClassification

public class RegisteredEndUser : EndUser
{
public ProfileWithDefaultMembership? Profile { get; set; }
public UserProfileWithDefaultMembership? Profile { get; set; }
}

public class EndUserWithMemberships : EndUser
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
using Application.Interfaces.Resources;
using Common;

namespace Application.Resources.Shared;

public class Profile : IIdentifiableResource
public class UserProfile : IIdentifiableResource
{
public ProfileAddress? Address { get; set; }
public ProfileAddress Address { get; set; } = new() { CountryCode = CountryCodes.Default.ToString() };

public string? AvatarUrl { get; set; }

Expand All @@ -18,9 +19,19 @@ public class Profile : IIdentifiableResource

public string? Timezone { get; set; }

public UserProfileType Type { get; set; }

public required string UserId { get; set; }

public required string Id { get; set; }
}

public enum UserProfileType
{
Person = 0,
Machine = 1
}

public class PersonName
{
public required string FirstName { get; set; }
Expand All @@ -45,7 +56,7 @@ public class ProfileAddress
public string? Zip { get; set; }
}

public class ProfileWithDefaultMembership : Profile
public class UserProfileWithDefaultMembership : UserProfile
{
public string? DefaultOrganizationId { get; set; }
}
6 changes: 6 additions & 0 deletions src/Application.Services.Shared/INotificationsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,10 @@ public interface INotificationsService
/// </summary>
Task<Result<Error>> NotifyPasswordRegistrationConfirmationAsync(ICallerContext caller, string emailAddress,
string name, string token, CancellationToken cancellationToken);

/// <summary>
/// Notifies a user, via email, to warn them that an attempt to re-register an account by another party has occurred
/// </summary>
Task<Result<Error>> NotifyReRegistrationCourtesyAsync(ICallerContext caller, string userId, string emailAddress,
string name, string? timezone, string? countryCode, CancellationToken cancellationToken);
}
19 changes: 19 additions & 0 deletions src/Application.Services.Shared/IUserProfilesService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Application.Interfaces;
using Application.Resources.Shared;
using Common;

namespace Application.Services.Shared;

public interface IUserProfilesService
{
Task<Result<UserProfile, Error>> CreateMachineProfilePrivateAsync(ICallerContext caller, string machineId,
string name,
string? timezone, string? countryCode, CancellationToken cancellationToken);

Task<Result<UserProfile, Error>> CreatePersonProfilePrivateAsync(ICallerContext caller, string personId,
string emailAddress,
string firstName, string? lastName, string? timezone, string? countryCode, CancellationToken cancellationToken);

Task<Result<Optional<UserProfile>, Error>> FindPersonByEmailAddressPrivateAsync(ICallerContext caller,
string emailAddress, CancellationToken cancellationToken);
}
4 changes: 2 additions & 2 deletions src/Domain.Shared.UnitTests/PersonNameSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace Domain.Shared.UnitTests;
public class PersonNameSpec
{
[Fact]
public void WhenWhenConstructWithFirstName_ThenReturnsFirstName()
public void WhenConstructWithFirstName_ThenReturnsFirstName()
{
var result = PersonName.Create("afirstname", Optional<string>.None).Value;

Expand All @@ -18,7 +18,7 @@ public void WhenWhenConstructWithFirstName_ThenReturnsFirstName()
}

[Fact]
public void WhenWhenConstructWithFirstNameAndLastName_ThenReturnsBothNames()
public void WhenConstructWithFirstNameAndLastName_ThenReturnsBothNames()
{
var result = PersonName.Create("afirstname", "alastname").Value;

Expand Down
35 changes: 35 additions & 0 deletions src/Domain.Shared.UnitTests/PhoneNumberSpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Common;
using FluentAssertions;
using UnitTesting.Common;
using Xunit;

namespace Domain.Shared.UnitTests;

[Trait("Category", "Unit")]
public class PhoneNumberSpec
{
[Fact]
public void WhenConstructWithInvalidNumber_ThenReturnsError()
{
var result = PhoneNumber.Create("aninvalidnumber");

result.Should().BeError(ErrorCode.Validation, Resources.PhoneNumber_InvalidPhoneNumber);
}

[Fact]
public void WhenConstructWithNationalNumber_ThenReturnsError()
{
var result = PhoneNumber.Create("098876986");

result.Should().BeError(ErrorCode.Validation, Resources.PhoneNumber_InvalidPhoneNumber);
}

[Fact]
public void WhenConstructWithInternationalNumber_ThenReturnsNumber()
{
var result = PhoneNumber.Create("+6498876986");

result.Should().BeSuccess();
result.Value.Number.Should().Be("+6498876986");
}
}
5 changes: 3 additions & 2 deletions src/Domain.Shared/Address.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ public sealed class Address : ValueObjectBase<Address>
{
public static readonly Address Default = Create(CountryCodes.Default).Value;

public static Result<Address, Error> Create(string line1, string line2, string line3, string city, string state,
CountryCodeIso3166 countryCode, string zip)
public static Result<Address, Error> Create(string? line1, string? line2, string? line3, string? city,
string? state,
CountryCodeIso3166 countryCode, string? zip)
{
return new Address(line1, line2, line3, city, state, countryCode, zip);
}
Expand Down
Loading

0 comments on commit e760b93

Please sign in to comment.