using ChessCubing.Server.Data; using Microsoft.Extensions.Options; using MySqlConnector; namespace ChessCubing.Server.Users; public sealed class MySqlUserProfileStore( IOptions options, ILogger 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 readonly SiteDataOptions _options = options.Value; private readonly ILogger _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 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 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> 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(); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { profiles.Add(MapProfile(reader)); } return profiles; } public async Task 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 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); } 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 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); }