Files
chesscubing/ChessCubing.Server/Users/MySqlUserProfileStore.cs

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);
}