using ChessCubing.Server.Data; using Microsoft.Extensions.Options; using MySqlConnector; namespace ChessCubing.Server.Social; public sealed class MySqlSocialStore( IOptions options, ILogger logger) { private const string CreateFriendshipsTableSql = """ CREATE TABLE IF NOT EXISTS social_friendships ( id BIGINT NOT NULL AUTO_INCREMENT, subject_low VARCHAR(190) NOT NULL, subject_high VARCHAR(190) NOT NULL, created_utc DATETIME(6) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_social_friendships_pair (subject_low, subject_high), KEY ix_social_friendships_low (subject_low), KEY ix_social_friendships_high (subject_high) ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; """; private const string CreateInvitationsTableSql = """ CREATE TABLE IF NOT EXISTS social_friend_invitations ( id BIGINT NOT NULL AUTO_INCREMENT, sender_subject VARCHAR(190) NOT NULL, recipient_subject VARCHAR(190) NOT NULL, created_utc DATETIME(6) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uq_social_friend_invitations_pair (sender_subject, recipient_subject), KEY ix_social_friend_invitations_sender (sender_subject), KEY ix_social_friend_invitations_recipient (recipient_subject) ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; """; private const string SelectOverviewFriendsSql = """ SELECT u.subject, u.username, u.display_name, u.email, u.club, u.city FROM social_friendships f INNER JOIN site_users u ON u.subject = CASE WHEN f.subject_low = @subject THEN f.subject_high ELSE f.subject_low END WHERE f.subject_low = @subject OR f.subject_high = @subject ORDER BY u.display_name ASC, u.username ASC; """; private const string SelectReceivedInvitationsSql = """ SELECT i.id, u.subject, u.username, u.display_name, u.email, i.created_utc FROM social_friend_invitations i INNER JOIN site_users u ON u.subject = i.sender_subject WHERE i.recipient_subject = @subject ORDER BY i.created_utc DESC, u.display_name ASC, u.username ASC; """; private const string SelectSentInvitationsSql = """ SELECT i.id, u.subject, u.username, u.display_name, u.email, i.created_utc FROM social_friend_invitations i INNER JOIN site_users u ON u.subject = i.recipient_subject WHERE i.sender_subject = @subject ORDER BY i.created_utc DESC, u.display_name ASC, u.username ASC; """; private const string SearchUsersTemplateSql = """ SELECT u.subject, u.username, u.display_name, u.email, u.club, u.city, EXISTS( SELECT 1 FROM social_friendships f WHERE (f.subject_low = @subject AND f.subject_high = u.subject) OR (f.subject_high = @subject AND f.subject_low = u.subject) ) AS is_friend, EXISTS( SELECT 1 FROM social_friend_invitations i WHERE i.sender_subject = @subject AND i.recipient_subject = u.subject ) AS has_sent_invitation, EXISTS( SELECT 1 FROM social_friend_invitations i WHERE i.sender_subject = u.subject AND i.recipient_subject = @subject ) AS has_received_invitation FROM site_users u WHERE u.subject <> @subject AND ( u.username LIKE @pattern OR u.display_name LIKE @pattern OR COALESCE(u.email, '') LIKE @pattern OR COALESCE(u.club, '') LIKE @pattern OR COALESCE(u.city, '') LIKE @pattern ) ORDER BY is_friend DESC, has_received_invitation DESC, has_sent_invitation DESC, u.display_name ASC, u.username ASC LIMIT @limit; """; private const string SelectRelevantPresenceSubjectsSql = """ SELECT participant_subject FROM ( SELECT CASE WHEN f.subject_low = @subject THEN f.subject_high ELSE f.subject_low END AS participant_subject FROM social_friendships f WHERE f.subject_low = @subject OR f.subject_high = @subject UNION SELECT i.sender_subject AS participant_subject FROM social_friend_invitations i WHERE i.recipient_subject = @subject UNION SELECT i.recipient_subject AS participant_subject FROM social_friend_invitations i WHERE i.sender_subject = @subject ) participants WHERE participant_subject <> @subject; """; private const string SelectFriendSubjectsSql = """ SELECT CASE WHEN f.subject_low = @subject THEN f.subject_high ELSE f.subject_low END AS friend_subject FROM social_friendships f WHERE f.subject_low = @subject OR f.subject_high = @subject; """; private const string SelectKnownUserSubjectSql = """ SELECT subject FROM site_users WHERE subject = @subject LIMIT 1; """; private const string SelectInvitationBetweenSql = """ SELECT id, sender_subject, recipient_subject FROM social_friend_invitations WHERE (sender_subject = @subjectA AND recipient_subject = @subjectB) OR (sender_subject = @subjectB AND recipient_subject = @subjectA) LIMIT 1; """; private const string SelectInvitationForRecipientSql = """ SELECT id, sender_subject, recipient_subject FROM social_friend_invitations WHERE id = @invitationId AND recipient_subject = @recipientSubject LIMIT 1; """; private const string SelectFriendshipExistsSql = """ SELECT 1 FROM social_friendships WHERE subject_low = @subjectLow AND subject_high = @subjectHigh LIMIT 1; """; private const string InsertInvitationSql = """ INSERT INTO social_friend_invitations ( sender_subject, recipient_subject, created_utc ) VALUES ( @senderSubject, @recipientSubject, @createdUtc ); """; private const string InsertFriendshipSql = """ INSERT IGNORE INTO social_friendships ( subject_low, subject_high, created_utc ) VALUES ( @subjectLow, @subjectHigh, @createdUtc ); """; private const string DeleteInvitationByRecipientSql = """ DELETE FROM social_friend_invitations WHERE id = @invitationId AND recipient_subject = @recipientSubject; """; private const string DeleteInvitationBySenderSql = """ DELETE FROM social_friend_invitations WHERE id = @invitationId AND sender_subject = @senderSubject; """; private const string DeleteInvitationsBetweenSql = """ DELETE FROM social_friend_invitations WHERE (sender_subject = @subjectA AND recipient_subject = @subjectB) OR (sender_subject = @subjectB AND recipient_subject = @subjectA); """; private const string DeleteFriendshipSql = """ DELETE FROM social_friendships WHERE subject_low = @subjectLow AND subject_high = @subjectHigh; """; private const string DeleteUserFriendshipsSql = """ DELETE FROM social_friendships WHERE subject_low = @subject OR subject_high = @subject; """; private const string DeleteUserInvitationsSql = """ DELETE FROM social_friend_invitations WHERE sender_subject = @subject OR recipient_subject = @subject; """; 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 CreateSchemaAsync(connection, cancellationToken); return; } catch (Exception exception) when (attempt < _options.InitializationRetries) { _logger.LogWarning( exception, "Initialisation MySQL impossible pour le module social (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 CreateSchemaAsync(finalConnection, cancellationToken); } public async Task GetOverviewAsync( string subject, Func isOnline, CancellationToken cancellationToken) { await using var connection = await OpenConnectionAsync(cancellationToken); var friends = new List(); await using (var command = connection.CreateCommand()) { command.CommandText = SelectOverviewFriendsSql; command.Parameters.AddWithValue("@subject", subject); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { var friendSubject = ReadString(reader, "subject"); friends.Add(new SocialFriendResponse { Subject = friendSubject, Username = ReadString(reader, "username"), DisplayName = ReadString(reader, "display_name"), Email = ReadNullableString(reader, "email"), Club = ReadNullableString(reader, "club"), City = ReadNullableString(reader, "city"), IsOnline = isOnline(friendSubject), }); } } var receivedInvitations = new List(); await using (var command = connection.CreateCommand()) { command.CommandText = SelectReceivedInvitationsSql; command.Parameters.AddWithValue("@subject", subject); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { var senderSubject = ReadString(reader, "subject"); receivedInvitations.Add(new SocialInvitationResponse { InvitationId = ReadInt64(reader, "id"), Subject = senderSubject, Username = ReadString(reader, "username"), DisplayName = ReadString(reader, "display_name"), Email = ReadNullableString(reader, "email"), CreatedUtc = ReadDateTime(reader, "created_utc"), IsOnline = isOnline(senderSubject), }); } } var sentInvitations = new List(); await using (var command = connection.CreateCommand()) { command.CommandText = SelectSentInvitationsSql; command.Parameters.AddWithValue("@subject", subject); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { var recipientSubject = ReadString(reader, "subject"); sentInvitations.Add(new SocialInvitationResponse { InvitationId = ReadInt64(reader, "id"), Subject = recipientSubject, Username = ReadString(reader, "username"), DisplayName = ReadString(reader, "display_name"), Email = ReadNullableString(reader, "email"), CreatedUtc = ReadDateTime(reader, "created_utc"), IsOnline = isOnline(recipientSubject), }); } } return new SocialOverviewResponse { Friends = friends.ToArray(), ReceivedInvitations = receivedInvitations.ToArray(), SentInvitations = sentInvitations.ToArray(), }; } public async Task> SearchUsersAsync( string subject, string? query, Func isOnline, CancellationToken cancellationToken) { var normalizedQuery = NormalizeQuery(query); if (normalizedQuery is null) { return []; } await using var connection = await OpenConnectionAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = SearchUsersTemplateSql; command.Parameters.AddWithValue("@subject", subject); command.Parameters.AddWithValue("@pattern", $"%{normalizedQuery}%"); command.Parameters.AddWithValue("@limit", 12); var results = new List(); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { var foundSubject = ReadString(reader, "subject"); results.Add(new SocialSearchUserResponse { Subject = foundSubject, Username = ReadString(reader, "username"), DisplayName = ReadString(reader, "display_name"), Email = ReadNullableString(reader, "email"), Club = ReadNullableString(reader, "club"), City = ReadNullableString(reader, "city"), IsOnline = isOnline(foundSubject), IsFriend = ReadBoolean(reader, "is_friend"), HasSentInvitation = ReadBoolean(reader, "has_sent_invitation"), HasReceivedInvitation = ReadBoolean(reader, "has_received_invitation"), }); } return results; } public async Task SendInvitationAsync( string senderSubject, string recipientSubject, CancellationToken cancellationToken) { if (string.Equals(senderSubject, recipientSubject, StringComparison.Ordinal)) { throw new SocialValidationException("Impossible de t'inviter toi-meme en ami."); } await using var connection = await OpenConnectionAsync(cancellationToken); await using var transaction = await connection.BeginTransactionAsync(cancellationToken); if (!await UserExistsAsync(connection, transaction, recipientSubject, cancellationToken)) { throw new SocialValidationException("Le joueur cible est introuvable."); } var pair = NormalizePair(senderSubject, recipientSubject); if (await FriendshipExistsAsync(connection, transaction, pair.SubjectLow, pair.SubjectHigh, cancellationToken)) { throw new SocialValidationException("Ce joueur fait deja partie de tes amis."); } var invitation = await ReadInvitationBetweenAsync(connection, transaction, senderSubject, recipientSubject, cancellationToken); if (invitation is not null) { throw new SocialValidationException( string.Equals(invitation.SenderSubject, senderSubject, StringComparison.Ordinal) ? "Une invitation est deja en attente pour ce joueur." : "Ce joueur t'a deja envoye une invitation. Accepte-la depuis la page utilisateur."); } await using (var command = connection.CreateCommand()) { command.Transaction = transaction; command.CommandText = InsertInvitationSql; command.Parameters.AddWithValue("@senderSubject", senderSubject); command.Parameters.AddWithValue("@recipientSubject", recipientSubject); command.Parameters.AddWithValue("@createdUtc", DateTime.UtcNow); await command.ExecuteNonQueryAsync(cancellationToken); } await transaction.CommitAsync(cancellationToken); } public async Task AcceptInvitationAsync(long invitationId, string recipientSubject, CancellationToken cancellationToken) { await using var connection = await OpenConnectionAsync(cancellationToken); await using var transaction = await connection.BeginTransactionAsync(cancellationToken); var invitation = await ReadInvitationForRecipientAsync(connection, transaction, invitationId, recipientSubject, cancellationToken) ?? throw new SocialValidationException("Invitation introuvable ou deja traitee."); var pair = NormalizePair(invitation.SenderSubject, invitation.RecipientSubject); await using (var command = connection.CreateCommand()) { command.Transaction = transaction; command.CommandText = InsertFriendshipSql; command.Parameters.AddWithValue("@subjectLow", pair.SubjectLow); command.Parameters.AddWithValue("@subjectHigh", pair.SubjectHigh); command.Parameters.AddWithValue("@createdUtc", DateTime.UtcNow); await command.ExecuteNonQueryAsync(cancellationToken); } await using (var command = connection.CreateCommand()) { command.Transaction = transaction; command.CommandText = DeleteInvitationsBetweenSql; command.Parameters.AddWithValue("@subjectA", invitation.SenderSubject); command.Parameters.AddWithValue("@subjectB", invitation.RecipientSubject); await command.ExecuteNonQueryAsync(cancellationToken); } await transaction.CommitAsync(cancellationToken); return invitation.SenderSubject; } public async Task DeclineInvitationAsync(long invitationId, string recipientSubject, CancellationToken cancellationToken) { await using var connection = await OpenConnectionAsync(cancellationToken); var invitation = await ReadInvitationForRecipientAsync(connection, transaction: null, invitationId, recipientSubject, cancellationToken) ?? throw new SocialValidationException("Invitation introuvable ou deja traitee."); await using var command = connection.CreateCommand(); command.CommandText = DeleteInvitationByRecipientSql; command.Parameters.AddWithValue("@invitationId", invitationId); command.Parameters.AddWithValue("@recipientSubject", recipientSubject); var deleted = await command.ExecuteNonQueryAsync(cancellationToken); if (deleted <= 0) { throw new SocialValidationException("Invitation introuvable ou deja traitee."); } return invitation.SenderSubject; } public async Task CancelInvitationAsync(long invitationId, string senderSubject, CancellationToken cancellationToken) { await using var connection = await OpenConnectionAsync(cancellationToken); var invitation = await ReadInvitationForSenderAsync(connection, transaction: null, invitationId, senderSubject, cancellationToken) ?? throw new SocialValidationException("Invitation introuvable ou deja retiree."); await using var command = connection.CreateCommand(); command.CommandText = DeleteInvitationBySenderSql; command.Parameters.AddWithValue("@invitationId", invitationId); command.Parameters.AddWithValue("@senderSubject", senderSubject); var deleted = await command.ExecuteNonQueryAsync(cancellationToken); if (deleted <= 0) { throw new SocialValidationException("Invitation introuvable ou deja retiree."); } return invitation.RecipientSubject; } public async Task RemoveFriendAsync(string subject, string friendSubject, CancellationToken cancellationToken) { var pair = NormalizePair(subject, friendSubject); await using var connection = await OpenConnectionAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = DeleteFriendshipSql; command.Parameters.AddWithValue("@subjectLow", pair.SubjectLow); command.Parameters.AddWithValue("@subjectHigh", pair.SubjectHigh); await command.ExecuteNonQueryAsync(cancellationToken); } public async Task AreFriendsAsync(string subject, string otherSubject, CancellationToken cancellationToken) { var pair = NormalizePair(subject, otherSubject); await using var connection = await OpenConnectionAsync(cancellationToken); return await FriendshipExistsAsync(connection, transaction: null, pair.SubjectLow, pair.SubjectHigh, cancellationToken); } public async Task> GetRelevantPresenceSubjectsAsync(string subject, CancellationToken cancellationToken) { await using var connection = await OpenConnectionAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = SelectRelevantPresenceSubjectsSql; command.Parameters.AddWithValue("@subject", subject); var subjects = new List(); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { subjects.Add(ReadString(reader, "participant_subject")); } return subjects; } public async Task> GetFriendSubjectsAsync(string subject, CancellationToken cancellationToken) { await using var connection = await OpenConnectionAsync(cancellationToken); await using var command = connection.CreateCommand(); command.CommandText = SelectFriendSubjectsSql; command.Parameters.AddWithValue("@subject", subject); var subjects = new List(); await using var reader = await command.ExecuteReaderAsync(cancellationToken); while (await reader.ReadAsync(cancellationToken)) { subjects.Add(ReadString(reader, "friend_subject")); } return subjects; } public async Task DeleteUserAsync(string subject, CancellationToken cancellationToken) { await using var connection = await OpenConnectionAsync(cancellationToken); await using (var command = connection.CreateCommand()) { command.CommandText = DeleteUserFriendshipsSql; command.Parameters.AddWithValue("@subject", subject); await command.ExecuteNonQueryAsync(cancellationToken); } await using (var command = connection.CreateCommand()) { command.CommandText = DeleteUserInvitationsSql; command.Parameters.AddWithValue("@subject", subject); await command.ExecuteNonQueryAsync(cancellationToken); } } private async Task CreateSchemaAsync(MySqlConnection connection, CancellationToken cancellationToken) { await using (var command = connection.CreateCommand()) { command.CommandText = CreateFriendshipsTableSql; await command.ExecuteNonQueryAsync(cancellationToken); } await using (var command = connection.CreateCommand()) { command.CommandText = CreateInvitationsTableSql; await command.ExecuteNonQueryAsync(cancellationToken); } } private async Task OpenConnectionAsync(CancellationToken cancellationToken) { var connection = new MySqlConnection(_options.BuildConnectionString()); await connection.OpenAsync(cancellationToken); return connection; } private static (string SubjectLow, string SubjectHigh) NormalizePair(string subjectA, string subjectB) => string.CompareOrdinal(subjectA, subjectB) <= 0 ? (subjectA, subjectB) : (subjectB, subjectA); private static string? NormalizeQuery(string? query) { var trimmed = query?.Trim(); if (string.IsNullOrWhiteSpace(trimmed)) { return null; } if (trimmed.Length < 2) { throw new SocialValidationException("La recherche d'amis demande au moins 2 caracteres."); } return trimmed.Length > 80 ? trimmed[..80] : trimmed; } private static async Task UserExistsAsync( MySqlConnection connection, MySqlTransaction transaction, string subject, CancellationToken cancellationToken) { await using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = SelectKnownUserSubjectSql; command.Parameters.AddWithValue("@subject", subject); return await command.ExecuteScalarAsync(cancellationToken) is not null; } private static async Task FriendshipExistsAsync( MySqlConnection connection, MySqlTransaction? transaction, string subjectLow, string subjectHigh, CancellationToken cancellationToken) { await using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = SelectFriendshipExistsSql; command.Parameters.AddWithValue("@subjectLow", subjectLow); command.Parameters.AddWithValue("@subjectHigh", subjectHigh); return await command.ExecuteScalarAsync(cancellationToken) is not null; } private static async Task ReadInvitationBetweenAsync( MySqlConnection connection, MySqlTransaction transaction, string subjectA, string subjectB, CancellationToken cancellationToken) { await using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = SelectInvitationBetweenSql; command.Parameters.AddWithValue("@subjectA", subjectA); command.Parameters.AddWithValue("@subjectB", subjectB); await using var reader = await command.ExecuteReaderAsync(cancellationToken); if (!await reader.ReadAsync(cancellationToken)) { return null; } return new InvitationRow( ReadInt64(reader, "id"), ReadString(reader, "sender_subject"), ReadString(reader, "recipient_subject")); } private static async Task ReadInvitationForRecipientAsync( MySqlConnection connection, MySqlTransaction? transaction, long invitationId, string recipientSubject, CancellationToken cancellationToken) { await using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = SelectInvitationForRecipientSql; command.Parameters.AddWithValue("@invitationId", invitationId); command.Parameters.AddWithValue("@recipientSubject", recipientSubject); await using var reader = await command.ExecuteReaderAsync(cancellationToken); if (!await reader.ReadAsync(cancellationToken)) { return null; } return new InvitationRow( ReadInt64(reader, "id"), ReadString(reader, "sender_subject"), ReadString(reader, "recipient_subject")); } private static async Task ReadInvitationForSenderAsync( MySqlConnection connection, MySqlTransaction? transaction, long invitationId, string senderSubject, CancellationToken cancellationToken) { await using var command = connection.CreateCommand(); command.Transaction = transaction; command.CommandText = """ SELECT id, sender_subject, recipient_subject FROM social_friend_invitations WHERE id = @invitationId AND sender_subject = @senderSubject LIMIT 1; """; command.Parameters.AddWithValue("@invitationId", invitationId); command.Parameters.AddWithValue("@senderSubject", senderSubject); await using var reader = await command.ExecuteReaderAsync(cancellationToken); if (!await reader.ReadAsync(cancellationToken)) { return null; } return new InvitationRow( ReadInt64(reader, "id"), ReadString(reader, "sender_subject"), ReadString(reader, "recipient_subject")); } private static bool ReadBoolean(MySqlDataReader reader, string columnName) => Convert.ToInt32(reader[columnName]) > 0; private static string ReadString(MySqlDataReader reader, string columnName) => reader.GetString(reader.GetOrdinal(columnName)); private static long ReadInt64(MySqlDataReader reader, string columnName) => reader.GetInt64(reader.GetOrdinal(columnName)); private static DateTime ReadDateTime(MySqlDataReader reader, string columnName) => reader.GetDateTime(reader.GetOrdinal(columnName)); private static string? ReadNullableString(MySqlDataReader reader, string columnName) { var ordinal = reader.GetOrdinal(columnName); return reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); } private sealed record InvitationRow(long Id, string SenderSubject, string RecipientSubject); }