408 lines
16 KiB
C#
408 lines
16 KiB
C#
using ChessCubing.Server.Data;
|
|
using Microsoft.Extensions.Options;
|
|
using MySqlConnector;
|
|
|
|
namespace ChessCubing.Server.Users;
|
|
|
|
public sealed class MySqlUserProfileStore(
|
|
IOptions<SiteDataOptions> options,
|
|
ILogger<MySqlUserProfileStore> logger)
|
|
{
|
|
private const string CreateTableSql = """
|
|
CREATE TABLE IF NOT EXISTS site_users (
|
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
|
subject VARCHAR(190) NOT NULL,
|
|
username VARCHAR(120) NOT NULL,
|
|
email VARCHAR(255) NULL,
|
|
display_name VARCHAR(120) NOT NULL,
|
|
club VARCHAR(120) NULL,
|
|
city VARCHAR(120) NULL,
|
|
preferred_format VARCHAR(40) NULL,
|
|
favorite_cube VARCHAR(120) NULL,
|
|
bio TEXT NULL,
|
|
created_utc DATETIME(6) NOT NULL,
|
|
updated_utc DATETIME(6) NOT NULL,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY uq_site_users_subject (subject)
|
|
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
""";
|
|
|
|
private const string UpsertIdentitySql = """
|
|
INSERT INTO site_users (
|
|
subject,
|
|
username,
|
|
email,
|
|
display_name,
|
|
created_utc,
|
|
updated_utc
|
|
)
|
|
VALUES (
|
|
@subject,
|
|
@username,
|
|
@email,
|
|
@displayName,
|
|
@createdUtc,
|
|
@updatedUtc
|
|
)
|
|
ON DUPLICATE KEY UPDATE
|
|
username = VALUES(username),
|
|
email = VALUES(email);
|
|
""";
|
|
|
|
private const string UpsertProfileSql = """
|
|
INSERT INTO site_users (
|
|
subject,
|
|
username,
|
|
email,
|
|
display_name,
|
|
club,
|
|
city,
|
|
preferred_format,
|
|
favorite_cube,
|
|
bio,
|
|
created_utc,
|
|
updated_utc
|
|
)
|
|
VALUES (
|
|
@subject,
|
|
@username,
|
|
@email,
|
|
@displayName,
|
|
@club,
|
|
@city,
|
|
@preferredFormat,
|
|
@favoriteCube,
|
|
@bio,
|
|
@createdUtc,
|
|
@updatedUtc
|
|
)
|
|
ON DUPLICATE KEY UPDATE
|
|
username = VALUES(username),
|
|
email = VALUES(email),
|
|
display_name = VALUES(display_name),
|
|
club = VALUES(club),
|
|
city = VALUES(city),
|
|
preferred_format = VALUES(preferred_format),
|
|
favorite_cube = VALUES(favorite_cube),
|
|
bio = VALUES(bio),
|
|
updated_utc = VALUES(updated_utc);
|
|
""";
|
|
|
|
private const string SelectProfileSql = """
|
|
SELECT
|
|
subject,
|
|
username,
|
|
email,
|
|
display_name,
|
|
club,
|
|
city,
|
|
preferred_format,
|
|
favorite_cube,
|
|
bio,
|
|
created_utc,
|
|
updated_utc
|
|
FROM site_users
|
|
WHERE subject = @subject
|
|
LIMIT 1;
|
|
""";
|
|
|
|
private const string SelectAllProfilesSql = """
|
|
SELECT
|
|
subject,
|
|
username,
|
|
email,
|
|
display_name,
|
|
club,
|
|
city,
|
|
preferred_format,
|
|
favorite_cube,
|
|
bio,
|
|
created_utc,
|
|
updated_utc
|
|
FROM site_users
|
|
ORDER BY updated_utc DESC, created_utc DESC, username ASC;
|
|
""";
|
|
|
|
private const string DeleteProfileSql = """
|
|
DELETE FROM site_users
|
|
WHERE subject = @subject;
|
|
""";
|
|
|
|
private readonly SiteDataOptions _options = options.Value;
|
|
private readonly ILogger<MySqlUserProfileStore> _logger = logger;
|
|
|
|
public async Task InitializeAsync(CancellationToken cancellationToken)
|
|
{
|
|
for (var attempt = 1; attempt <= _options.InitializationRetries; attempt++)
|
|
{
|
|
try
|
|
{
|
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
|
await connection.OpenAsync(cancellationToken);
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = CreateTableSql;
|
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
|
return;
|
|
}
|
|
catch (Exception exception) when (attempt < _options.InitializationRetries)
|
|
{
|
|
_logger.LogWarning(
|
|
exception,
|
|
"Initialisation MySQL impossible pour le profil utilisateur (tentative {Attempt}/{MaxAttempts}).",
|
|
attempt,
|
|
_options.InitializationRetries);
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(_options.InitializationDelaySeconds), cancellationToken);
|
|
}
|
|
}
|
|
|
|
await using var finalConnection = new MySqlConnection(_options.BuildConnectionString());
|
|
await finalConnection.OpenAsync(cancellationToken);
|
|
|
|
await using var finalCommand = finalConnection.CreateCommand();
|
|
finalCommand.CommandText = CreateTableSql;
|
|
await finalCommand.ExecuteNonQueryAsync(cancellationToken);
|
|
}
|
|
|
|
public async Task<UserProfileResponse> GetOrCreateAsync(
|
|
AuthenticatedSiteUser user,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
|
await connection.OpenAsync(cancellationToken);
|
|
|
|
var nowUtc = DateTime.UtcNow;
|
|
|
|
await using (var command = connection.CreateCommand())
|
|
{
|
|
command.CommandText = UpsertIdentitySql;
|
|
command.Parameters.AddWithValue("@subject", user.Subject);
|
|
command.Parameters.AddWithValue("@username", user.Username);
|
|
command.Parameters.AddWithValue("@email", (object?)user.Email ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@displayName", user.DisplayName);
|
|
command.Parameters.AddWithValue("@createdUtc", nowUtc);
|
|
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
|
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
|
}
|
|
|
|
return await ReadProfileAsync(connection, user.Subject, cancellationToken);
|
|
}
|
|
|
|
public async Task<UserProfileResponse> UpdateAsync(
|
|
AuthenticatedSiteUser user,
|
|
UpdateUserProfileRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var input = NormalizeInput(user, request);
|
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
|
await connection.OpenAsync(cancellationToken);
|
|
|
|
var nowUtc = DateTime.UtcNow;
|
|
|
|
await using (var command = connection.CreateCommand())
|
|
{
|
|
command.CommandText = UpsertProfileSql;
|
|
command.Parameters.AddWithValue("@subject", user.Subject);
|
|
command.Parameters.AddWithValue("@username", user.Username);
|
|
command.Parameters.AddWithValue("@email", (object?)user.Email ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@displayName", input.DisplayName);
|
|
command.Parameters.AddWithValue("@club", (object?)input.Club ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@city", (object?)input.City ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@preferredFormat", (object?)input.PreferredFormat ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@favoriteCube", (object?)input.FavoriteCube ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@bio", (object?)input.Bio ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@createdUtc", nowUtc);
|
|
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
|
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
|
}
|
|
|
|
return await ReadProfileAsync(connection, user.Subject, cancellationToken);
|
|
}
|
|
|
|
public void ValidateAdminUpdate(string fallbackDisplayName, UpdateUserProfileRequest request)
|
|
=> _ = NormalizeInput(fallbackDisplayName, request);
|
|
|
|
public async Task<IReadOnlyList<UserProfileResponse>> ListAsync(CancellationToken cancellationToken)
|
|
{
|
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
|
await connection.OpenAsync(cancellationToken);
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = SelectAllProfilesSql;
|
|
|
|
var profiles = new List<UserProfileResponse>();
|
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
|
while (await reader.ReadAsync(cancellationToken))
|
|
{
|
|
profiles.Add(MapProfile(reader));
|
|
}
|
|
|
|
return profiles;
|
|
}
|
|
|
|
public async Task<UserProfileResponse?> FindBySubjectAsync(string subject, CancellationToken cancellationToken)
|
|
{
|
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
|
await connection.OpenAsync(cancellationToken);
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = SelectProfileSql;
|
|
command.Parameters.AddWithValue("@subject", subject);
|
|
|
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
|
return await reader.ReadAsync(cancellationToken)
|
|
? MapProfile(reader)
|
|
: null;
|
|
}
|
|
|
|
public async Task<UserProfileResponse> AdminUpsertAsync(
|
|
string subject,
|
|
string username,
|
|
string? email,
|
|
string fallbackDisplayName,
|
|
UpdateUserProfileRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var input = NormalizeInput(fallbackDisplayName, request);
|
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
|
await connection.OpenAsync(cancellationToken);
|
|
|
|
var nowUtc = DateTime.UtcNow;
|
|
|
|
await using (var command = connection.CreateCommand())
|
|
{
|
|
command.CommandText = UpsertProfileSql;
|
|
command.Parameters.AddWithValue("@subject", subject);
|
|
command.Parameters.AddWithValue("@username", username);
|
|
command.Parameters.AddWithValue("@email", (object?)email ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@displayName", input.DisplayName);
|
|
command.Parameters.AddWithValue("@club", (object?)input.Club ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@city", (object?)input.City ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@preferredFormat", (object?)input.PreferredFormat ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@favoriteCube", (object?)input.FavoriteCube ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@bio", (object?)input.Bio ?? DBNull.Value);
|
|
command.Parameters.AddWithValue("@createdUtc", nowUtc);
|
|
command.Parameters.AddWithValue("@updatedUtc", nowUtc);
|
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
|
}
|
|
|
|
return await ReadProfileAsync(connection, subject, cancellationToken);
|
|
}
|
|
|
|
public async Task DeleteAsync(string subject, CancellationToken cancellationToken)
|
|
{
|
|
await using var connection = new MySqlConnection(_options.BuildConnectionString());
|
|
await connection.OpenAsync(cancellationToken);
|
|
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = DeleteProfileSql;
|
|
command.Parameters.AddWithValue("@subject", subject);
|
|
await command.ExecuteNonQueryAsync(cancellationToken);
|
|
}
|
|
|
|
private static UserProfileInput NormalizeInput(AuthenticatedSiteUser user, UpdateUserProfileRequest request)
|
|
=> NormalizeInput(user.DisplayName, request);
|
|
|
|
private static UserProfileInput NormalizeInput(string fallbackDisplayName, UpdateUserProfileRequest request)
|
|
{
|
|
var displayName = NormalizeOptionalValue(request.DisplayName, "nom affiche", 120) ?? fallbackDisplayName;
|
|
var club = NormalizeOptionalValue(request.Club, "club", 120);
|
|
var city = NormalizeOptionalValue(request.City, "ville", 120);
|
|
var favoriteCube = NormalizeOptionalValue(request.FavoriteCube, "cube favori", 120);
|
|
var bio = NormalizeOptionalValue(request.Bio, "bio", 1200);
|
|
var preferredFormat = NormalizePreferredFormat(request.PreferredFormat);
|
|
|
|
return new UserProfileInput(displayName, club, city, preferredFormat, favoriteCube, bio);
|
|
}
|
|
|
|
private static string? NormalizeOptionalValue(string? value, string fieldName, int maxLength)
|
|
{
|
|
var trimmed = value?.Trim();
|
|
if (string.IsNullOrWhiteSpace(trimmed))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (trimmed.Length > maxLength)
|
|
{
|
|
throw new UserProfileValidationException($"Le champ {fieldName} depasse {maxLength} caracteres.");
|
|
}
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
private static string? NormalizePreferredFormat(string? value)
|
|
{
|
|
var normalized = NormalizeOptionalValue(value, "format prefere", 40);
|
|
if (normalized is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return normalized switch
|
|
{
|
|
"Twice" => "Twice",
|
|
"Time" => "Time",
|
|
"Les deux" => "Les deux",
|
|
_ => throw new UserProfileValidationException("Le format prefere doit etre Twice, Time ou Les deux."),
|
|
};
|
|
}
|
|
|
|
private static async Task<UserProfileResponse> ReadProfileAsync(
|
|
MySqlConnection connection,
|
|
string subject,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var command = connection.CreateCommand();
|
|
command.CommandText = SelectProfileSql;
|
|
command.Parameters.AddWithValue("@subject", subject);
|
|
|
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
|
if (!await reader.ReadAsync(cancellationToken))
|
|
{
|
|
throw new InvalidOperationException("Le profil utilisateur n'a pas pu etre charge.");
|
|
}
|
|
|
|
return MapProfile(reader);
|
|
}
|
|
|
|
private static UserProfileResponse MapProfile(MySqlDataReader reader)
|
|
{
|
|
var subjectOrdinal = reader.GetOrdinal("subject");
|
|
var usernameOrdinal = reader.GetOrdinal("username");
|
|
var emailOrdinal = reader.GetOrdinal("email");
|
|
var displayNameOrdinal = reader.GetOrdinal("display_name");
|
|
var clubOrdinal = reader.GetOrdinal("club");
|
|
var cityOrdinal = reader.GetOrdinal("city");
|
|
var preferredFormatOrdinal = reader.GetOrdinal("preferred_format");
|
|
var favoriteCubeOrdinal = reader.GetOrdinal("favorite_cube");
|
|
var bioOrdinal = reader.GetOrdinal("bio");
|
|
var createdUtcOrdinal = reader.GetOrdinal("created_utc");
|
|
var updatedUtcOrdinal = reader.GetOrdinal("updated_utc");
|
|
|
|
return new UserProfileResponse
|
|
{
|
|
Subject = reader.GetString(subjectOrdinal),
|
|
Username = reader.GetString(usernameOrdinal),
|
|
Email = reader.IsDBNull(emailOrdinal) ? null : reader.GetString(emailOrdinal),
|
|
DisplayName = reader.GetString(displayNameOrdinal),
|
|
Club = reader.IsDBNull(clubOrdinal) ? null : reader.GetString(clubOrdinal),
|
|
City = reader.IsDBNull(cityOrdinal) ? null : reader.GetString(cityOrdinal),
|
|
PreferredFormat = reader.IsDBNull(preferredFormatOrdinal) ? null : reader.GetString(preferredFormatOrdinal),
|
|
FavoriteCube = reader.IsDBNull(favoriteCubeOrdinal) ? null : reader.GetString(favoriteCubeOrdinal),
|
|
Bio = reader.IsDBNull(bioOrdinal) ? null : reader.GetString(bioOrdinal),
|
|
CreatedUtc = DateTime.SpecifyKind(reader.GetDateTime(createdUtcOrdinal), DateTimeKind.Utc),
|
|
UpdatedUtc = DateTime.SpecifyKind(reader.GetDateTime(updatedUtcOrdinal), DateTimeKind.Utc),
|
|
};
|
|
}
|
|
|
|
private sealed record UserProfileInput(
|
|
string DisplayName,
|
|
string? Club,
|
|
string? City,
|
|
string? PreferredFormat,
|
|
string? FavoriteCube,
|
|
string? Bio);
|
|
}
|