Skip to content

Commit

Permalink
Retrieve contact information for multiple users (#209)
Browse files Browse the repository at this point in the history
* Correct name of container and solution folder

* Fix comment typos

* Use pattern matching for type check

* Refactoring GetUserListByUuid to better fit it with the other methods

* Correct typo in comment

* Refactoring to arrow function for simplification

* Cleaning up namespace for Core's ServiceCollectionExtensions

* Add suffix "Service" to IUnitContactPoints

* Correct namespace ..User.ContactPoints -> Unit.ContactPoints for IUnitContactPointsService

* Simplify list initialization

* Generate database models

* Move the database context class to its own folder

* Update the ContextClassName attribute

* Implemented an endpoint to retrieve user contact information efficiently.

* Organize each component into its appropriate namespace

* Remove the unused dependencies and improve the service collection extension methods

* Implement a simple validation logic

* Specify the expected request and response content types

* Code refactoring to achieve a clean separation of concerns, enhance flexibility, and improve the maintainability and testability

* Code refactoring

* Keep the validation in the service layer and use the AutoMapper to move mapping logic out of service.

* Add a number of unit tests

* Improve the unit tests

* Code refactoring

* Add a validation rule to validate national security numbers

* Rearrange core extensions

* Organize the matched SSN and the unmatched SSN in a respone object

* Implement validation layer and refactor existing code.

* Implement a use case for controllers to access and retrieve users’ contact details

* Add unit tests for string extension methods

* Code refactoring

* Remove old unit tests

* Fix typos

* Fix a conflict

* Rewrite a number of unit tests

* Improve a comment

* Update the internal endpoint address

* Shorten the error message

* Remove the parameter name argument.

* Remove two unused variables

* Changed the Register name to Person

* Add the default parameter value defined in the overridden method.

* Remove the nameof parameter

* Remove an unnecessary check for null

* Rename the service using the table name

* Improve code smell

* Code refactoring

* Implement two unit tests to validate the functionality of the user contact details retrieval use case.

* Create a new Migration folder and attach the database creation script

* Regenerate the data models

* Move the Person class to the entities folder

* Change the table name from Register to Person

* Rename a number of classes

* Rename some classes

* Rename more classes

* Code refactoring

* Remove an unnecessary check for null

* Remove an unnecessary check for null

* Add a simple unit test to test the public endpoint

* Add a number of unit tests to test the public endpoint

* Inject a logger to record information whenever an exception occurs

* Rename the unit tests

* Check the error message

* Implement a single unit test to ensure that the controller does not block unnecessarily by mocking long-running tasks.

* Implement unit tests to verify the functionality of the class responsible for validating national identity numbers

* Implement unit tests to test retrieval of contact details

* Update the internal endpoint to produce the same result as the external one

* Fix a typo

* Add three new unit tests

* Copy some unit tests from the external controller to the internal one

* Change the national identity number

* Try to avoid duplicates

* Check model validity

* Implement two unit tests to verify the outcome when the data model is invalid.

* Rename the unit tests

* Update the unit tests to check the returned values

* Increase the unit tests to cover valid and invalid national identity numbers, retrieving contact details, handling empty results, and managing repository exceptions gracefully in the PersonService

* Use different types

* Fix an issue introduced in the previous commit

* Add the Reservation flag to the test data

* Renamed a file

* Add a unit test to test retrieving contact details

* Code refactoring

* Code refactoring

* Add a missing dot

* Rename a folder

* Code refactoring

* Cover more use cases

* Increase covered use cases

* Follow the same naming pattern

* Implement a number of simple unit test to test the mapping profile

* Delete the DROP DATABASE IF EXISTS statement

* Delete an index that could be used to search for person data by a supplier identifier

* Add statements to grant two users, we have, access to the schema

* Remove the database creation statement

* Regenerate the Person data model

* Move the data models to the integration project

* Fix connection string

---------

Co-authored-by: Hallgeir Garnes-Gutvik <hallgeir.garnes-gutvik@digdir.no>
Co-authored-by: Terje Holene <terje.holene@gmail.com>
  • Loading branch information
3 people authored Oct 14, 2024
1 parent b9182bb commit 7683f19
Show file tree
Hide file tree
Showing 44 changed files with 2,897 additions and 104 deletions.
93 changes: 93 additions & 0 deletions src/Altinn.Profile.Core/Domain/IRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#nullable enable

namespace Altinn.Profile.Core.Domain;

/// <summary>
/// Defines generic methods for handling entities in a repository.
/// </summary>
/// <typeparam name="T">The type of the entity.</typeparam>
public interface IRepository<T>
where T : class
{
/// <summary>
/// Asynchronously retrieves all entities.
/// </summary>
/// <returns>A task that represents the asynchronous operation. The task result contains a collection of entities.</returns>
Task<IEnumerable<T>> GetAllAsync();

/// <summary>
/// Asynchronously retrieves entities with optional filtering, sorting, and pagination.
/// </summary>
/// <param name="filter">Optional filter criteria.</param>
/// <param name="orderBy">Optional ordering criteria.</param>
/// <param name="skip">The number of entities to skip.</param>
/// <param name="take">The number of entities to take.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains a collection of filtered, sorted, and paginated entities.</returns>
Task<IEnumerable<T>> GetAsync(
Func<T, bool>? filter = null,
Func<IEnumerable<T>, IOrderedEnumerable<T>>? orderBy = null,
int? skip = null,
int? take = null);

/// <summary>
/// Asynchronously retrieves an entity by its identifier.
/// </summary>
/// <param name="id">The identifier of the entity.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the entity that matches the identifier.</returns>
Task<T?> GetByIdAsync(string id);

/// <summary>
/// Asynchronously checks if an entity exists by its identifier.
/// </summary>
/// <param name="id">The identifier of the entity.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains a boolean indicating the existence of the entity.</returns>
Task<bool> ExistsAsync(string id);

/// <summary>
/// Asynchronously adds a new entity.
/// </summary>
/// <param name="entity">The entity to add.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the added entity.</returns>
Task<T> AddAsync(T entity);

/// <summary>
/// Asynchronously adds multiple entities.
/// </summary>
/// <param name="entities">The entities to add.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task AddRangeAsync(IEnumerable<T> entities);

/// <summary>
/// Asynchronously updates an existing entity.
/// </summary>
/// <param name="entity">The entity to update.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task UpdateAsync(T entity);

/// <summary>
/// Asynchronously updates multiple entities.
/// </summary>
/// <param name="entities">The entities to update.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task UpdateRangeAsync(IEnumerable<T> entities);

/// <summary>
/// Asynchronously deletes an entity by its identifier.
/// </summary>
/// <param name="id">The identifier of the entity to delete.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task DeleteAsync(string id);

/// <summary>
/// Asynchronously deletes multiple entities by their identifiers.
/// </summary>
/// <param name="ids">The identifiers of the entities to delete.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
Task DeleteRangeAsync(IEnumerable<string> ids);

/// <summary>
/// Saves changes to the data source asynchronously.
/// </summary>
/// <returns>A task that represents the asynchronous save operation. The task result contains the number of state entries written to the data source.</returns>
Task<int> SaveChangesAsync();
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Altinn.Profile.Core;
namespace Altinn.Profile.Core.Extensions;

/// <summary>
/// Extension class for <see cref="IServiceCollection"/>
Expand Down
178 changes: 178 additions & 0 deletions src/Altinn.Profile.Core/Extensions/StringExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Text.RegularExpressions;

namespace Altinn.Profile.Core.Extensions;

/// <summary>
/// Extension class for <see cref="string"/> to add more members.
/// </summary>
public static partial class StringExtensions
{
/// <summary>
/// Determines whether a given string consists of only digits.
/// </summary>
/// <param name="input">The string to check.</param>
/// <returns>
/// <c>true</c> if the given string consists of only digits; otherwise, <c>false</c>.
/// </returns>
/// <remarks>
/// This method checks if the provided string is not null or whitespace and matches the regex pattern for digits.
/// The regex pattern ensures that the string contains only numeric characters (0-9).
/// </remarks>
public static bool IsDigitsOnly(this string input)
{
if (string.IsNullOrWhiteSpace(input))
{
return false;
}

return DigitsOnlyRegex().IsMatch(input);
}

/// <summary>
/// Removes all whitespace characters from the given string.
/// </summary>
/// <param name="stringToClean">The string from which to remove whitespace characters.</param>
/// <returns>
/// A new string with all whitespace characters removed.
/// If the input is null, empty, or consists only of whitespace, the original input is returned.
/// </returns>
public static string? RemoveWhitespace(this string stringToClean)
{
if (string.IsNullOrWhiteSpace(stringToClean))
{
return stringToClean?.Trim();
}

return WhitespaceRegex().Replace(stringToClean, string.Empty);
}

/// <summary>
/// Determines whether a given string represents a valid format for a Norwegian Social Security Number (SSN).
/// </summary>
/// <param name="socialSecurityNumber">The Norwegian Social Security Number (SSN) to validate.</param>
/// <param name="controlDigits">Indicates whether to validate the control digits.</param>
/// <returns>
/// <c>true</c> if the given string represents a valid format for a Norwegian Social Security Number (SSN) and, if specified, the control digits are valid; otherwise, <c>false</c>.
/// </returns>
/// <remarks>
/// A valid Norwegian Social Security Number (SSN) is an 11-digit number where:
/// - The first six digits represent the date of birth in the format DDMMYY.
/// - The next three digits are an individual number where the first digit indicates the century of birth.
/// - The last two digits are control digits.
/// </remarks>
/// <exception cref="FormatException">Thrown when the individual number part of the SSN cannot be parsed into an integer.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when the parsed date is outside the range of DateTime.</exception>
public static bool IsValidSocialSecurityNumber(this string socialSecurityNumber, bool controlDigits = true)
{
if (string.IsNullOrWhiteSpace(socialSecurityNumber) || socialSecurityNumber.Length != 11)
{
return false;
}

// Return the cached result if the given string has been checked once.
if (CachedSocialSecurityNumber.TryGetValue(socialSecurityNumber, out var cachedResult))
{
return cachedResult;
}

ReadOnlySpan<char> socialSecurityNumberSpan = socialSecurityNumber.AsSpan();

for (int i = 0; i < socialSecurityNumberSpan.Length; i++)
{
if (!char.IsDigit(socialSecurityNumberSpan[i]))
{
return false;
}
}

// Extract parts of the Social Security Number (SSN) using slicing.
ReadOnlySpan<char> datePart = socialSecurityNumberSpan[..6];
ReadOnlySpan<char> controlDigitsPart = socialSecurityNumberSpan[9..11];
ReadOnlySpan<char> individualNumberPart = socialSecurityNumberSpan[6..9];

// If parsing the individual number part fails, return false.
if (!int.TryParse(individualNumberPart, out _))
{
return false;
}

// Validate the date part.
if (!DateTime.TryParseExact(datePart.ToString(), "ddMMyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
{
return false;
}

var isValidSocialSecurityNumber = !controlDigits || CalculateControlDigits(socialSecurityNumberSpan[..9].ToString()) == controlDigitsPart.ToString();

CachedSocialSecurityNumber.TryAdd(socialSecurityNumber, isValidSocialSecurityNumber);

return isValidSocialSecurityNumber;
}

/// <summary>
/// Calculates the control digits used to validate a Norwegian Social Security Number.
/// </summary>
/// <param name="firstNineDigits">The first nine digits of the Social Security Number.</param>
/// <returns>A <see cref="string"/> represents the two control digits.</returns>
private static string CalculateControlDigits(string firstNineDigits)
{
int[] weightsFirst = [3, 7, 6, 1, 8, 9, 4, 5, 2];

int[] weightsSecond = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2];

int firstControlDigit = CalculateControlDigit(firstNineDigits, weightsFirst);

int secondControlDigit = CalculateControlDigit(firstNineDigits + firstControlDigit, weightsSecond);

return $"{firstControlDigit}{secondControlDigit}";
}

/// <summary>
/// Calculates a control digit using the specified weights.
/// </summary>
/// <param name="digits">The digits to use in the calculation.</param>
/// <param name="weights">The weights for each digit.</param>
/// <returns>An <see cref="int"/> represents the calculated control digit.</returns>
private static int CalculateControlDigit(string digits, int[] weights)
{
int sum = 0;

for (int i = 0; i < weights.Length; i++)
{
sum += (int)char.GetNumericValue(digits[i]) * weights[i];
}

int remainder = sum % 11;
return remainder == 0 ? 0 : 11 - remainder;
}

/// <summary>
/// Generates a compiled regular expression for matching all whitespace characters in a string.
/// </summary>
/// <returns>
/// A <see cref="Regex"/> object that can be used to match all whitespace characters in a string.
/// </returns>
[GeneratedRegex(@"\s+", RegexOptions.Compiled)]
private static partial Regex WhitespaceRegex();

/// <summary>
/// Generates a compiled regular expression for validating that a string consists of only digits.
/// </summary>
/// <returns>
/// A <see cref="Regex"/> object that can be used to validate that a string contains only digits.
/// </returns>
[GeneratedRegex(@"^\d+$", RegexOptions.Compiled)]
private static partial Regex DigitsOnlyRegex();

/// <summary>
/// A cache for storing the validation results of Norwegian Social Security Numbers (SSNs).
/// </summary>
/// <remarks>
/// This cache helps to avoid redundant validation checks for SSNs that have already been processed.
/// It maps the SSN as a string to a boolean indicating whether the SSN is valid (true) or not (false).
/// Utilizing this cache can significantly improve performance for applications that frequently validate the same SSNs.
/// </remarks>
private static ConcurrentDictionary<string, bool> CachedSocialSecurityNumber => new();
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class UserContactPointAvailability
public int UserId { get; set; }

/// <summary>
/// Gets or sets the national identityt number of the user
/// Gets or sets the national identity number of the user
/// </summary>
public string NationalIdentityNumber { get; set; } = string.Empty;

Expand All @@ -37,7 +37,7 @@ public class UserContactPointAvailability
public class UserContactPointAvailabilityList
{
/// <summary>
/// A list containing contact point availabiliy for users
/// A list containing contact point availability for users
/// </summary>
public List<UserContactPointAvailability> AvailabilityList { get; set; } = [];
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.8" />
<PackageReference Include="AutoMapper" Version="13.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Altinn.Profile.Core\Altinn.Profile.Core.csproj" />
<InternalsVisibleTo Include="Altinn.Profile.Tests" />
</ItemGroup>

<ItemGroup Condition="'$(Configuration)'=='Debug'">
Expand Down
34 changes: 34 additions & 0 deletions src/Altinn.Profile.Integrations/Entities/IPersonContactDetails.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#nullable enable

namespace Altinn.Profile.Integrations.Entities;

/// <summary>
/// Represents a person's contact details.
/// </summary>
public interface IPersonContactDetails
{
/// <summary>
/// Gets the national identity number of the person.
/// </summary>
string NationalIdentityNumber { get; }

/// <summary>
/// Gets a value indicating whether the person opts out of being contacted.
/// </summary>
bool? IsReserved { get; }

/// <summary>
/// Gets the mobile phone number of the person.
/// </summary>
string? MobilePhoneNumber { get; }

/// <summary>
/// Gets the email address of the person.
/// </summary>
string? EmailAddress { get; }

/// <summary>
/// Gets the language code of the person, represented as an ISO 639-1 code.
/// </summary>
string? LanguageCode { get; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#nullable enable

using System.Collections.Immutable;

namespace Altinn.Profile.Integrations.Entities;

/// <summary>
/// Represents the result of a lookup operation for contact details.
/// </summary>
public interface IPersonContactDetailsLookupResult
{
/// <summary>
/// Gets a list of national identity numbers that could not be matched with any person contact details.
/// </summary>
/// <value>
/// An <see cref="ImmutableList{T}"/> of <see cref="string"/> containing the unmatched national identity numbers.
/// </value>
ImmutableList<string>? UnmatchedNationalIdentityNumbers { get; }

/// <summary>
/// Gets a list of person contact details that were successfully matched during the lookup.
/// </summary>
/// <value>
/// An <see cref="ImmutableList{T}"/> of <see cref="IPersonContactDetails"/> containing the matched person contact details.
/// </value>
ImmutableList<IPersonContactDetails>? MatchedPersonContactDetails { get; }
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
// <auto-generated> This file has been auto generated by EF Core Power Tools. </auto-generated>
#nullable disable

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

using Microsoft.EntityFrameworkCore;

namespace Altinn.Profile.Models;
namespace Altinn.Profile.Integrations.Entities;

/// <summary>
/// Represents a mailbox supplier in the contact and reservation schema.
Expand Down
Loading

0 comments on commit 7683f19

Please sign in to comment.