Skip to content

Commit

Permalink
Added Guest Invitation APIs
Browse files Browse the repository at this point in the history
  • Loading branch information
jezzsantos committed Mar 31, 2024
1 parent e760b93 commit 514fdb3
Show file tree
Hide file tree
Showing 93 changed files with 3,952 additions and 435 deletions.
12 changes: 9 additions & 3 deletions docs/design-principles/0150-all-use-cases.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ These are the main use cases of this product that are exposed via "public" APIs

### Users (End User)

1. Assign [platform] roles to the current user
1. Assign [platform] roles to an existing user
1. Unassign [platform] roles to an existing user (except `Standard` role)
1. Invite a guest to register on the platform (a referral)
1. Resend an invitation to a guest
1. Guest confirms an invitation is still valid

### Identities

Expand All @@ -83,6 +87,7 @@ These are the main use cases of this product that are exposed via "public" APIs
1. Authenticate the current user (with a password)
2. Register a new person (with a password)
3. Confirm registration of a person (from email)
4. Accept a guest invitation (with a password)

#### Single-Sign On

Expand All @@ -96,15 +101,16 @@ TBD

1. Create a new organization for the current user
2. Inspect a specific organization
3.
3.

### Subscriptions

TBD

### User Profiles

TBD
1. Change the names, phone, time zone of the profile,
2. Change the address of the profile

## BEFFE

Expand Down
69 changes: 69 additions & 0 deletions docs/design-principles/0160-user-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# User Lifecycle

## Design Principles

* We want to use the email address as a unique identifier for each person (in the universe), and we only want one representation of that person in all organizations (tenants). However, a person can have several identities (a specific company identity versus a personal identity).
* We want any registered user in the system to invite another person (via an email address) to register themselves to the system, to capture the email address early.
* Only the invited person can self-register after they were "warm" invited, or they approached the platform "cold".
* When registering, regardless of whether they were invited via an existing email address or not, they can choose the email address they wish to register with.
*

## Implementation

Users are managed in the `EndUser` subdomain.

Users cannot be deleted. Once registered, they can be `disabled` and suspended from using the product.

Once registered, the email address they used to register will be used as their username. Even though they may change that email address, no two users on the platform can use the same email address.

Users can self-register from the `Identity` subdomain via either `PasswordCredentials`, `SSOUsers`, or other methods, each responsible for its own onboarding flow (e.g., passwords require email confirmations and a managed registration flow, whereas SSO can just automate it).

New users can be invited by another party (authenticated or not) and can respond to that invitation or register without an invite.

![User Lifecycle](../images/EndUser-Lifecycle.png)

### Organizations

An `Organization` has a type of either `Personal` or `Shared`.

* `Shared` organizations are intended for use by companies/workgroups/organizations/teams/etc and for persons and machines.
* `Personal` organizations are for each `EndUser` (person or machine) to use on the platform and cannot be shared with others.
* A person/machine will have a membership to one `Personal` organization at all times, and can have a membership to one or more `Shared` organizations.

#### Roles and Responsibilities

* Any person can have the `Member` and/or `Owner` and/or `BillingAdmin` roles in an organization.
* Only `Owner` roles can assign/unassign roles to other members.
* Any person can have the `BillingAdmin` role of an organization, but they must also have the `Owner` role.
* Every organization (`Shared` or `Personal`) will always have a person who is the "billing subscriber". This person has a fiscal responsibility to pay for billing charges for that organization (tenant). The "billing subscriber" must always have the `Owner` and `BillingAdmin` roles at all times.
* When a person creates a new organization, they automatically become an `Owner` and `BillingAdmin` of it, as well as the "billing subscriber" for it.
* From that point, they can assign the `Owner` and `BillingAdmin` roles to one or more other members of the organization.
* The "billing subscriber" responsibility must be transferred via a (voluntary) payment method submission from another person with the `BillingAdmin` (role).

#### Personal Organizations

* Every `EndUser` (person or machine) has one `Personal` organization.

* It is automatically created for them when they register on the platform. It is named after that person/machine.
* That person/machine is the only member of that organization.

* They have the roles of `Owner` and `BillingAdmin`, and they are also the "billing subscriber" for it.

* This organization cannot be deleted, and that person cannot be removed from it, nor can their roles be changed.

> This organization is very important so that the product can be accessed at all times by them, regardless of whether the owner is a member of any `Shared` organizations or not.
#### Shared Organizations

* `Shared` organizations can be created at any time by any person on the platform (not machines).
* Any other person/machine can be invited into them. When they are, they are created a `Membership` to that `Organization`, and each `Membership` maintains its own roles.
* A person (or machine) can be a `Member` (role) of any number of `Shared` organizations into which they can be invited, removed, or they can leave themselves.
* A person (not a machine) can be assigned/unassigned any number of roles in those other organizations.
* A `Shared` organization must have at least one `Owner` (role) and one `BillingAdmin` (role) at all times, and they can be the same person or different persons. Like a `Personal` organization, a `Shared` will have one and only one "billing subscriber", who is ultimately responsible for any charges for the organization.
* A `Shared` organization can be deleted. However, they have to be deleted by the designated "billing subscriber" and only once all members are removed from it.

### Invitations

TBD how do they work?

Memberships etc.
10 changes: 6 additions & 4 deletions docs/design-principles/README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
# Design Principles

[All Use Cases](0150-all-use-cases.md) the main use cases that we have implemented across the product (so that you do not have to implement them yourselves)

* [REST API Design Guidelines](0010-rest-api.md) how REST API's should be designed
* [REST API Framework](0020-api-framework.md) how REST API's are implemented
* [Modularity](0025-modularity.md) how we build modules that can be scaled-out later as the product grows
* [Modularity](0025-modularity.md) is how we build modules that can be scaled-out later as the product grows
* [Recording/Logging/etc](0030-recording.md) how we do crash reporting, logging, auditing, and capture usage metrics
* [Configuration Management](0040-configuration.md) how we manage configuration in the source code at design-time and runtime
* [Domain Driven Design](0050-domain-driven-design.md) how to design your aggregates, and domains
* [Dependency Injection](0060-dependency-injection.md) how you implement DI
* [Persistence](0070-persistence.md) how you design your repository layer, and promote domain events
* [Ports and Adapters](0080-ports-and-adapters.md) how we keep infrastructure components at arms length, and testable, and how we integrate with any 3rd party system
* [Ports and Adapters](0080-ports-and-adapters.md) how we keep infrastructure components at arm's length, and testable, and how we integrate with any 3rd party system
* [Authentication and Authorization](0090-authentication-authorization.md) how we authenticate and authorize users
* [Email Delivery](0100-email-delivery.md) how we send emails and deliver them asynchronously and reliably
* [Backend for Frontend](0110-back-end-for-front-end.md) the BEFFE web server that is tailored for a web UI, and brokers secure access to the backend
* [Feature Flagging](0120-feature-flagging.md) how we enable and disable features at runtime
* [Multi-Tenancy](0130-multitenancy.md) how we support multiple tenants in our system (both logically and physical infrastructure)
* [Multi-Tenancy](0130-multitenancy.md) how we support multiple tenants in our system (both logical and physical infrastructure)
* [Developer Tooling](0140-developer-tooling.md) all the tooling that is included in this codebase to help developers use this codebase effectively, and consistently
* [All Use Cases](0150-all-use-cases.md) the main use cases that we have implemented across the product (so you dont have to)
* [User Lifecycle](0160-user-lifecycle.md) how are users managed on the platform, and the relationship to their organizations
2 changes: 1 addition & 1 deletion src/AncillaryInfrastructure/Persistence/AuditRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
using Application.Persistence.Common.Extensions;
using Application.Persistence.Interfaces;
using Common;
using Common.Extensions;
using Domain.Common.ValueObjects;
using Domain.Interfaces;
using Infrastructure.Persistence.Common;
using Infrastructure.Persistence.Interfaces;
using QueryAny;
using Tasks = Common.Extensions.Tasks;

namespace AncillaryInfrastructure.Persistence;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Infrastructure.Persistence.Common;
using Infrastructure.Persistence.Interfaces;
using QueryAny;
using Tasks = Common.Extensions.Tasks;

namespace AncillaryInfrastructure.Persistence;

Expand Down
9 changes: 9 additions & 0 deletions src/Application.Interfaces/Audits.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Application.Interfaces/Audits.resx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@
<data name="EndUserApplication_PlatformRolesAssigned" xml:space="preserve">
<value>EndUser.PlatformRolesAssigned</value>
</data>
<data name="EndUserApplication_PlatformRolesUnassigned" xml:space="preserve">
<value>EndUser.PlatformRolesUnassigned</value>
</data>
<data name="SingleSignOnApplication_Authenticate_AccountOnboarded" xml:space="preserve">
<value>SingleSignOn.AutoRegistered</value>
</data>
Expand Down
1 change: 1 addition & 0 deletions src/Application.Interfaces/UsageConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public static class UsageScenarios
public const string Audit = "Audited";
public const string BookingCancelled = "Booking Cancelled";
public const string BookingCreated = "Booking Created";
public const string GuestInvited = "User Guest Invited";
public const string MachineRegistered = "Machine Registered";
public const string Measurement = "Measured";
public const string PersonRegistrationConfirmed = "User Registered";
Expand Down
22 changes: 22 additions & 0 deletions src/Application.Persistence.Common/Extensions/Tasks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Common;

namespace Application.Persistence.Common.Extensions;

public static class Tasks
{
/// <summary>
/// Runs all the specified <see cref="tasks" /> and returns the first <see cref="Error" /> if any
/// </summary>
public static async Task<Result<bool, Error>> WhenAllAsync(params Task<Result<bool, Error>>[] tasks)
{
var results = await Task.WhenAll(tasks);

var hasError = results.Any(result => !result.IsSuccessful);
if (hasError)
{
return results.First(result => !result.IsSuccessful).Error;
}

return results.All(result => result.Value);
}
}
9 changes: 9 additions & 0 deletions src/Application.Resources.Shared/EndUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,13 @@ public class Membership : IIdentifiableResource
public List<string> Roles { get; set; } = new();

public required string Id { get; set; }
}

public class Invitation
{
public required string EmailAddress { get; set; }

public required string FirstName { get; set; }

public string? LastName { get; set; }
}
5 changes: 5 additions & 0 deletions src/Application.Services.Shared/IEndUsersService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ namespace Application.Services.Shared;

public interface IEndUsersService
{
Task<Result<RegisteredEndUser, Error>> AcceptGuestInvitationPrivateAsync(ICallerContext caller, string token,
string emailAddress,
string firstName, string lastName, string? timezone, string? countryCode, bool termsAndConditionsAccepted,
CancellationToken cancellationToken);

Task<Result<Membership, Error>> CreateMembershipForCallerPrivateAsync(ICallerContext caller, string organizationId,
CancellationToken cancellationToken);

Expand Down
6 changes: 6 additions & 0 deletions src/Application.Services.Shared/INotificationsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ namespace Application.Services.Shared;
/// </summary>
public interface INotificationsService
{
/// <summary>
/// Notifies a user, via email, that they have been invited to register with the platform
/// </summary>
Task<Result<Error>> NotifyGuestInvitationToPlatformAsync(ICallerContext caller, string token,
string inviteeEmailAddress, string inviteeName, string inviterName, CancellationToken cancellationToken);

/// <summary>
/// Notifies a user, via email, to confirm their account registration
/// </summary>
Expand Down
3 changes: 3 additions & 0 deletions src/Application.Services.Shared/IUserProfilesService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,7 @@ Task<Result<UserProfile, Error>> CreatePersonProfilePrivateAsync(ICallerContext

Task<Result<Optional<UserProfile>, Error>> FindPersonByEmailAddressPrivateAsync(ICallerContext caller,
string emailAddress, CancellationToken cancellationToken);

Task<Result<UserProfile, Error>> GetProfilePrivateAsync(ICallerContext caller, string userId,
CancellationToken cancellationToken);
}
2 changes: 2 additions & 0 deletions src/Application.Services.Shared/IWebsiteUiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ namespace Application.Services.Shared;
public interface IWebsiteUiService
{
string ConstructPasswordRegistrationConfirmationPageUrl(string token);

string CreateRegistrationPageUrl(string token);
}
2 changes: 1 addition & 1 deletion src/CarsInfrastructure/Persistence/CarRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
using CarsApplication.Persistence.ReadModels;
using CarsDomain;
using Common;
using Common.Extensions;
using Domain.Common.ValueObjects;
using Domain.Interfaces;
using Infrastructure.Persistence.Common;
using Infrastructure.Persistence.Interfaces;
using QueryAny;
using Unavailability = CarsApplication.Persistence.ReadModels.Unavailability;
using Tasks = Common.Extensions.Tasks;

namespace CarsInfrastructure.Persistence;

Expand Down
2 changes: 2 additions & 0 deletions src/Domain.Services.Shared/DomainServices/ITokensService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ public interface ITokensService
{
APIKeyToken CreateAPIKey();

string CreateGuestInvitationToken();

string CreateJWTRefreshToken();

string CreatePasswordResetToken();
Expand Down
16 changes: 8 additions & 8 deletions src/Domain.Shared.UnitTests/EmailAddressSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public void WhenConstructedAndInvalidEmail_ThenReturnsError()
[Fact]
public void WhenGuessPersonNameFromEmailAndPlainUsername_ThenReturnsName()
{
var name = EmailAddress.Create("[email protected]").Value.GuessPersonName();
var name = EmailAddress.Create("[email protected]").Value.GuessPersonFullName();

name.FirstName.Text.Should().Be("Auser");
name.LastName.Should().BeNone();
Expand All @@ -36,7 +36,7 @@ public void WhenGuessPersonNameFromEmailAndPlainUsername_ThenReturnsName()
[Fact]
public void WhenGuessPersonNameFromEmailAndMultipleDottedUsername_ThenReturnsName()
{
var name = EmailAddress.Create("[email protected]").Value.GuessPersonName();
var name = EmailAddress.Create("[email protected]").Value.GuessPersonFullName();

name.FirstName.Text.Should().Be("Afirstname");
name.LastName.Value.Text.Should().Be("Alastname");
Expand All @@ -45,7 +45,7 @@ public void WhenGuessPersonNameFromEmailAndMultipleDottedUsername_ThenReturnsNam
[Fact]
public void WhenGuessPersonNameFromEmailAndTwoDottedUsername_ThenReturnsName()
{
var name = EmailAddress.Create("[email protected]").Value.GuessPersonName();
var name = EmailAddress.Create("[email protected]").Value.GuessPersonFullName();

name.FirstName.Text.Should().Be("Afirstname");
name.LastName.Value.Text.Should().Be("Alastname");
Expand All @@ -54,7 +54,7 @@ public void WhenGuessPersonNameFromEmailAndTwoDottedUsername_ThenReturnsName()
[Fact]
public void WhenGuessPersonNameFromEmailAndContainsPlusSign_ThenReturnsName()
{
var name = EmailAddress.Create("[email protected]").Value.GuessPersonName();
var name = EmailAddress.Create("[email protected]").Value.GuessPersonFullName();

name.FirstName.Text.Should().Be("Afirstname");
name.LastName.Should().BeNone();
Expand All @@ -63,7 +63,7 @@ public void WhenGuessPersonNameFromEmailAndContainsPlusSign_ThenReturnsName()
[Fact]
public void WhenGuessPersonNameFromEmailAndContainsPlusSignAndNumber_ThenReturnsName()
{
var name = EmailAddress.Create("[email protected]").Value.GuessPersonName();
var name = EmailAddress.Create("[email protected]").Value.GuessPersonFullName();

name.FirstName.Text.Should().Be("Afirstname");
name.LastName.Should().BeNone();
Expand All @@ -72,7 +72,7 @@ public void WhenGuessPersonNameFromEmailAndContainsPlusSignAndNumber_ThenReturns
[Fact]
public void WhenGuessPersonNameFromEmailAndGuessedFirstNameNotValid_ThenReturnsNameWithFallbackFirstName()
{
var name = EmailAddress.Create("[email protected]").Value.GuessPersonName();
var name = EmailAddress.Create("[email protected]").Value.GuessPersonFullName();

name.FirstName.Text.Should().Be(Resources.EmailAddress_FallbackGuessedFirstName);
name.LastName.Should().BeNone();
Expand All @@ -81,7 +81,7 @@ public void WhenGuessPersonNameFromEmailAndGuessedFirstNameNotValid_ThenReturnsN
[Fact]
public void WhenGuessPersonNameFromEmailAndGuessedLastNameNotValid_ThenReturnsNameWithNoLastName()
{
var name = EmailAddress.Create("[email protected]").Value.GuessPersonName();
var name = EmailAddress.Create("[email protected]").Value.GuessPersonFullName();

name.FirstName.Text.Should().Be("Afirstname");
name.LastName.Should().BeNone();
Expand All @@ -90,7 +90,7 @@ public void WhenGuessPersonNameFromEmailAndGuessedLastNameNotValid_ThenReturnsNa
[Fact]
public void WhenGuessPersonNameFromEmailAndGuessedFirstAndLastNameNotValid_ThenReturnsNameWithNoLastName()
{
var name = EmailAddress.Create("[email protected]").Value.GuessPersonName();
var name = EmailAddress.Create("[email protected]").Value.GuessPersonFullName();

name.FirstName.Text.Should().Be(Resources.EmailAddress_FallbackGuessedFirstName);
name.LastName.Should().BeNone();
Expand Down
2 changes: 1 addition & 1 deletion src/Domain.Shared/EmailAddress.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public static ValueObjectFactory<EmailAddress> Rehydrate()
}

[SkipImmutabilityCheck]
public PersonName GuessPersonName()
public PersonName GuessPersonFullName()
{
var name = GuessPersonNameFromEmailAddress(Value);

Expand Down
Loading

0 comments on commit 514fdb3

Please sign in to comment.