Ajoute les amis et les invitations temps reel
This commit is contained in:
60
ChessCubing.Server/Social/ConnectedUserTracker.cs
Normal file
60
ChessCubing.Server/Social/ConnectedUserTracker.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
namespace ChessCubing.Server.Social;
|
||||
|
||||
public sealed class ConnectedUserTracker
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<string, string> _subjectByConnection = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, HashSet<string>> _connectionsBySubject = new(StringComparer.Ordinal);
|
||||
|
||||
public bool TrackConnection(string connectionId, string subject)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_subjectByConnection[connectionId] = subject;
|
||||
|
||||
if (!_connectionsBySubject.TryGetValue(subject, out var connections))
|
||||
{
|
||||
connections = new HashSet<string>(StringComparer.Ordinal);
|
||||
_connectionsBySubject[subject] = connections;
|
||||
}
|
||||
|
||||
var wasOffline = connections.Count == 0;
|
||||
connections.Add(connectionId);
|
||||
return wasOffline;
|
||||
}
|
||||
}
|
||||
|
||||
public (string? Subject, bool BecameOffline) RemoveConnection(string connectionId)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_subjectByConnection.Remove(connectionId, out var subject))
|
||||
{
|
||||
return (null, false);
|
||||
}
|
||||
|
||||
if (!_connectionsBySubject.TryGetValue(subject, out var connections))
|
||||
{
|
||||
return (subject, false);
|
||||
}
|
||||
|
||||
connections.Remove(connectionId);
|
||||
if (connections.Count > 0)
|
||||
{
|
||||
return (subject, false);
|
||||
}
|
||||
|
||||
_connectionsBySubject.Remove(subject);
|
||||
return (subject, true);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsOnline(string subject)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _connectionsBySubject.TryGetValue(subject, out var connections)
|
||||
&& connections.Count > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
787
ChessCubing.Server/Social/MySqlSocialStore.cs
Normal file
787
ChessCubing.Server/Social/MySqlSocialStore.cs
Normal file
@@ -0,0 +1,787 @@
|
||||
using ChessCubing.Server.Data;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MySqlConnector;
|
||||
|
||||
namespace ChessCubing.Server.Social;
|
||||
|
||||
public sealed class MySqlSocialStore(
|
||||
IOptions<SiteDataOptions> options,
|
||||
ILogger<MySqlSocialStore> 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<MySqlSocialStore> _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<SocialOverviewResponse> GetOverviewAsync(
|
||||
string subject,
|
||||
Func<string, bool> isOnline,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await OpenConnectionAsync(cancellationToken);
|
||||
|
||||
var friends = new List<SocialFriendResponse>();
|
||||
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<SocialInvitationResponse>();
|
||||
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<SocialInvitationResponse>();
|
||||
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<IReadOnlyList<SocialSearchUserResponse>> SearchUsersAsync(
|
||||
string subject,
|
||||
string? query,
|
||||
Func<string, bool> 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<SocialSearchUserResponse>();
|
||||
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<string> 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<string> 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<string> 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<bool> 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<IReadOnlyList<string>> 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<string>();
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
subjects.Add(ReadString(reader, "participant_subject"));
|
||||
}
|
||||
|
||||
return subjects;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> 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<string>();
|
||||
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<MySqlConnection> 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<bool> 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<bool> 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<InvitationRow?> 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<InvitationRow?> 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<InvitationRow?> 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);
|
||||
}
|
||||
211
ChessCubing.Server/Social/PlayInviteCoordinator.cs
Normal file
211
ChessCubing.Server/Social/PlayInviteCoordinator.cs
Normal file
@@ -0,0 +1,211 @@
|
||||
namespace ChessCubing.Server.Social;
|
||||
|
||||
public sealed class PlayInviteCoordinator
|
||||
{
|
||||
private static readonly TimeSpan InviteLifetime = TimeSpan.FromMinutes(2);
|
||||
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<string, PlayInviteState> _invitesById = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _inviteByParticipant = new(StringComparer.Ordinal);
|
||||
|
||||
public PlayInviteMessage CreateInvite(PlayInviteParticipant sender, PlayInviteParticipant recipient, string recipientColor)
|
||||
{
|
||||
var normalizedColor = NormalizeRecipientColor(recipientColor);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
CleanupExpiredUnsafe();
|
||||
|
||||
if (_inviteByParticipant.ContainsKey(sender.Subject))
|
||||
{
|
||||
throw new SocialValidationException("Une invitation de partie est deja en cours pour ton compte.");
|
||||
}
|
||||
|
||||
if (_inviteByParticipant.ContainsKey(recipient.Subject))
|
||||
{
|
||||
throw new SocialValidationException("Cet ami traite deja une autre invitation de partie.");
|
||||
}
|
||||
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var state = new PlayInviteState(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
sender,
|
||||
recipient,
|
||||
normalizedColor,
|
||||
nowUtc,
|
||||
nowUtc.Add(InviteLifetime));
|
||||
|
||||
_invitesById[state.InviteId] = state;
|
||||
_inviteByParticipant[sender.Subject] = state.InviteId;
|
||||
_inviteByParticipant[recipient.Subject] = state.InviteId;
|
||||
|
||||
return MapInvite(state);
|
||||
}
|
||||
}
|
||||
|
||||
public PlayInviteCloseResult CancelInvite(string inviteId, string senderSubject)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var state = GetActiveInviteUnsafe(inviteId);
|
||||
if (!string.Equals(state.Sender.Subject, senderSubject, StringComparison.Ordinal))
|
||||
{
|
||||
throw new SocialValidationException("Seul l'expediteur peut annuler cette invitation.");
|
||||
}
|
||||
|
||||
RemoveInviteUnsafe(state);
|
||||
return new PlayInviteCloseResult(
|
||||
state.Sender.Subject,
|
||||
state.Recipient.Subject,
|
||||
new PlayInviteClosedMessage
|
||||
{
|
||||
InviteId = state.InviteId,
|
||||
Reason = "cancelled",
|
||||
Message = $"{state.Sender.DisplayName} a annule l'invitation de partie.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public PlayInviteCloseResult DeclineInvite(string inviteId, string recipientSubject)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var state = GetActiveInviteUnsafe(inviteId);
|
||||
if (!string.Equals(state.Recipient.Subject, recipientSubject, StringComparison.Ordinal))
|
||||
{
|
||||
throw new SocialValidationException("Seul le destinataire peut refuser cette invitation.");
|
||||
}
|
||||
|
||||
RemoveInviteUnsafe(state);
|
||||
return new PlayInviteCloseResult(
|
||||
state.Sender.Subject,
|
||||
state.Recipient.Subject,
|
||||
new PlayInviteClosedMessage
|
||||
{
|
||||
InviteId = state.InviteId,
|
||||
Reason = "declined",
|
||||
Message = $"{state.Recipient.DisplayName} a refuse la partie.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public PlayInviteAcceptResult AcceptInvite(string inviteId, string recipientSubject)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var state = GetActiveInviteUnsafe(inviteId);
|
||||
if (!string.Equals(state.Recipient.Subject, recipientSubject, StringComparison.Ordinal))
|
||||
{
|
||||
throw new SocialValidationException("Seul le destinataire peut accepter cette invitation.");
|
||||
}
|
||||
|
||||
RemoveInviteUnsafe(state);
|
||||
|
||||
var white = string.Equals(state.RecipientColor, "white", StringComparison.Ordinal)
|
||||
? state.Recipient
|
||||
: state.Sender;
|
||||
var black = string.Equals(state.RecipientColor, "white", StringComparison.Ordinal)
|
||||
? state.Sender
|
||||
: state.Recipient;
|
||||
|
||||
var session = new PlaySessionResponse
|
||||
{
|
||||
SessionId = Guid.NewGuid().ToString("N"),
|
||||
WhiteSubject = white.Subject,
|
||||
WhiteName = white.DisplayName,
|
||||
BlackSubject = black.Subject,
|
||||
BlackName = black.DisplayName,
|
||||
InitiatorSubject = state.Sender.Subject,
|
||||
RecipientSubject = state.Recipient.Subject,
|
||||
ConfirmedUtc = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
return new PlayInviteAcceptResult(state.Sender.Subject, state.Recipient.Subject, session);
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeRecipientColor(string recipientColor)
|
||||
{
|
||||
var normalized = recipientColor.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"white" => "white",
|
||||
"black" => "black",
|
||||
_ => throw new SocialValidationException("La couleur demandee pour l'ami doit etre blanc ou noir."),
|
||||
};
|
||||
}
|
||||
|
||||
private static PlayInviteMessage MapInvite(PlayInviteState state)
|
||||
=> new()
|
||||
{
|
||||
InviteId = state.InviteId,
|
||||
SenderSubject = state.Sender.Subject,
|
||||
SenderUsername = state.Sender.Username,
|
||||
SenderDisplayName = state.Sender.DisplayName,
|
||||
RecipientSubject = state.Recipient.Subject,
|
||||
RecipientUsername = state.Recipient.Username,
|
||||
RecipientDisplayName = state.Recipient.DisplayName,
|
||||
RecipientColor = state.RecipientColor,
|
||||
CreatedUtc = state.CreatedUtc,
|
||||
ExpiresUtc = state.ExpiresUtc,
|
||||
};
|
||||
|
||||
private PlayInviteState GetActiveInviteUnsafe(string inviteId)
|
||||
{
|
||||
CleanupExpiredUnsafe();
|
||||
|
||||
if (!_invitesById.TryGetValue(inviteId, out var state))
|
||||
{
|
||||
throw new SocialValidationException("Cette invitation de partie n'est plus disponible.");
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
private void CleanupExpiredUnsafe()
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var expiredInviteIds = _invitesById.Values
|
||||
.Where(state => state.ExpiresUtc <= nowUtc)
|
||||
.Select(state => state.InviteId)
|
||||
.ToArray();
|
||||
|
||||
foreach (var expiredInviteId in expiredInviteIds)
|
||||
{
|
||||
if (_invitesById.TryGetValue(expiredInviteId, out var state))
|
||||
{
|
||||
RemoveInviteUnsafe(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveInviteUnsafe(PlayInviteState state)
|
||||
{
|
||||
_invitesById.Remove(state.InviteId);
|
||||
_inviteByParticipant.Remove(state.Sender.Subject);
|
||||
_inviteByParticipant.Remove(state.Recipient.Subject);
|
||||
}
|
||||
|
||||
private sealed record PlayInviteState(
|
||||
string InviteId,
|
||||
PlayInviteParticipant Sender,
|
||||
PlayInviteParticipant Recipient,
|
||||
string RecipientColor,
|
||||
DateTime CreatedUtc,
|
||||
DateTime ExpiresUtc);
|
||||
}
|
||||
|
||||
public readonly record struct PlayInviteParticipant(
|
||||
string Subject,
|
||||
string Username,
|
||||
string DisplayName);
|
||||
|
||||
public sealed record PlayInviteCloseResult(
|
||||
string SenderSubject,
|
||||
string RecipientSubject,
|
||||
PlayInviteClosedMessage ClosedMessage);
|
||||
|
||||
public sealed record PlayInviteAcceptResult(
|
||||
string SenderSubject,
|
||||
string RecipientSubject,
|
||||
PlaySessionResponse Session);
|
||||
137
ChessCubing.Server/Social/SocialContracts.cs
Normal file
137
ChessCubing.Server/Social/SocialContracts.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
namespace ChessCubing.Server.Social;
|
||||
|
||||
public sealed class SocialOverviewResponse
|
||||
{
|
||||
public SocialFriendResponse[] Friends { get; init; } = [];
|
||||
|
||||
public SocialInvitationResponse[] ReceivedInvitations { get; init; } = [];
|
||||
|
||||
public SocialInvitationResponse[] SentInvitations { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class SocialFriendResponse
|
||||
{
|
||||
public string Subject { get; init; } = string.Empty;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
public string? Email { get; init; }
|
||||
|
||||
public string? Club { get; init; }
|
||||
|
||||
public string? City { get; init; }
|
||||
|
||||
public bool IsOnline { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SocialInvitationResponse
|
||||
{
|
||||
public long InvitationId { get; init; }
|
||||
|
||||
public string Subject { get; init; } = string.Empty;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
public string? Email { get; init; }
|
||||
|
||||
public bool IsOnline { get; init; }
|
||||
|
||||
public DateTime CreatedUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SocialSearchUserResponse
|
||||
{
|
||||
public string Subject { get; init; } = string.Empty;
|
||||
|
||||
public string Username { get; init; } = string.Empty;
|
||||
|
||||
public string DisplayName { get; init; } = string.Empty;
|
||||
|
||||
public string? Email { get; init; }
|
||||
|
||||
public string? Club { get; init; }
|
||||
|
||||
public string? City { get; init; }
|
||||
|
||||
public bool IsOnline { get; init; }
|
||||
|
||||
public bool IsFriend { get; init; }
|
||||
|
||||
public bool HasSentInvitation { get; init; }
|
||||
|
||||
public bool HasReceivedInvitation { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SendFriendInvitationRequest
|
||||
{
|
||||
public string TargetSubject { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class PresenceSnapshotMessage
|
||||
{
|
||||
public string[] OnlineSubjects { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class PresenceChangedMessage
|
||||
{
|
||||
public string Subject { get; init; } = string.Empty;
|
||||
|
||||
public bool IsOnline { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PlayInviteMessage
|
||||
{
|
||||
public string InviteId { get; init; } = string.Empty;
|
||||
|
||||
public string SenderSubject { get; init; } = string.Empty;
|
||||
|
||||
public string SenderUsername { get; init; } = string.Empty;
|
||||
|
||||
public string SenderDisplayName { get; init; } = string.Empty;
|
||||
|
||||
public string RecipientSubject { get; init; } = string.Empty;
|
||||
|
||||
public string RecipientUsername { get; init; } = string.Empty;
|
||||
|
||||
public string RecipientDisplayName { get; init; } = string.Empty;
|
||||
|
||||
public string RecipientColor { get; init; } = string.Empty;
|
||||
|
||||
public DateTime CreatedUtc { get; init; }
|
||||
|
||||
public DateTime ExpiresUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PlayInviteClosedMessage
|
||||
{
|
||||
public string InviteId { get; init; } = string.Empty;
|
||||
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class PlaySessionResponse
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
|
||||
public string WhiteSubject { get; init; } = string.Empty;
|
||||
|
||||
public string WhiteName { get; init; } = string.Empty;
|
||||
|
||||
public string BlackSubject { get; init; } = string.Empty;
|
||||
|
||||
public string BlackName { get; init; } = string.Empty;
|
||||
|
||||
public string InitiatorSubject { get; init; } = string.Empty;
|
||||
|
||||
public string RecipientSubject { get; init; } = string.Empty;
|
||||
|
||||
public DateTime ConfirmedUtc { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SocialValidationException(string message) : Exception(message);
|
||||
168
ChessCubing.Server/Social/SocialHub.cs
Normal file
168
ChessCubing.Server/Social/SocialHub.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using ChessCubing.Server.Users;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ChessCubing.Server.Social;
|
||||
|
||||
[Authorize]
|
||||
public sealed class SocialHub(
|
||||
ConnectedUserTracker tracker,
|
||||
MySqlSocialStore socialStore,
|
||||
MySqlUserProfileStore profileStore,
|
||||
PlayInviteCoordinator playInviteCoordinator) : Hub
|
||||
{
|
||||
private readonly ConnectedUserTracker _tracker = tracker;
|
||||
private readonly MySqlSocialStore _socialStore = socialStore;
|
||||
private readonly MySqlUserProfileStore _profileStore = profileStore;
|
||||
private readonly PlayInviteCoordinator _playInviteCoordinator = playInviteCoordinator;
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
var siteUser = RequireCurrentUser();
|
||||
var becameOnline = _tracker.TrackConnection(Context.ConnectionId, siteUser.Subject);
|
||||
|
||||
await SendPresenceSnapshotAsync(siteUser.Subject, Context.ConnectionAborted);
|
||||
|
||||
if (becameOnline)
|
||||
{
|
||||
var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(siteUser.Subject, Context.ConnectionAborted);
|
||||
await NotifyPresenceChangedAsync(relevantSubjects, siteUser.Subject, isOnline: true);
|
||||
}
|
||||
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
|
||||
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||
{
|
||||
var (subject, becameOffline) = _tracker.RemoveConnection(Context.ConnectionId);
|
||||
if (becameOffline && !string.IsNullOrWhiteSpace(subject))
|
||||
{
|
||||
var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(subject, CancellationToken.None);
|
||||
await NotifyPresenceChangedAsync(relevantSubjects, subject, isOnline: false);
|
||||
}
|
||||
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
public async Task RequestPresenceSnapshot()
|
||||
{
|
||||
var siteUser = RequireCurrentUser();
|
||||
await SendPresenceSnapshotAsync(siteUser.Subject, Context.ConnectionAborted);
|
||||
}
|
||||
|
||||
public async Task<PlayInviteMessage> SendPlayInvite(string recipientSubject, string recipientColor)
|
||||
{
|
||||
var sender = RequireCurrentUser();
|
||||
var normalizedRecipientSubject = recipientSubject.Trim();
|
||||
|
||||
if (!await _socialStore.AreFriendsAsync(sender.Subject, normalizedRecipientSubject, Context.ConnectionAborted))
|
||||
{
|
||||
throw new HubException("Seuls tes amis peuvent recevoir une invitation de partie.");
|
||||
}
|
||||
|
||||
if (!_tracker.IsOnline(normalizedRecipientSubject))
|
||||
{
|
||||
throw new HubException("Cet ami n'est plus connecte pour le moment.");
|
||||
}
|
||||
|
||||
var senderProfile = await _profileStore.GetOrCreateAsync(sender, Context.ConnectionAborted);
|
||||
var recipientProfile = await _profileStore.FindBySubjectAsync(normalizedRecipientSubject, Context.ConnectionAborted);
|
||||
if (recipientProfile is null)
|
||||
{
|
||||
throw new HubException("Le profil de cet ami est introuvable.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var message = _playInviteCoordinator.CreateInvite(
|
||||
new PlayInviteParticipant(sender.Subject, sender.Username, senderProfile.DisplayName),
|
||||
new PlayInviteParticipant(recipientProfile.Subject, recipientProfile.Username, recipientProfile.DisplayName),
|
||||
recipientColor);
|
||||
|
||||
await Clients.Users([sender.Subject, recipientProfile.Subject])
|
||||
.SendAsync("PlayInviteUpdated", message, Context.ConnectionAborted);
|
||||
|
||||
return message;
|
||||
}
|
||||
catch (SocialValidationException exception)
|
||||
{
|
||||
throw new HubException(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RespondToPlayInvite(string inviteId, bool accept)
|
||||
{
|
||||
var recipient = RequireCurrentUser();
|
||||
|
||||
try
|
||||
{
|
||||
if (accept)
|
||||
{
|
||||
var accepted = _playInviteCoordinator.AcceptInvite(inviteId, recipient.Subject);
|
||||
await Clients.Users([accepted.SenderSubject, accepted.RecipientSubject])
|
||||
.SendAsync("PlayInviteAccepted", accepted.Session, Context.ConnectionAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
var declined = _playInviteCoordinator.DeclineInvite(inviteId, recipient.Subject);
|
||||
await Clients.Users([declined.SenderSubject, declined.RecipientSubject])
|
||||
.SendAsync("PlayInviteClosed", declined.ClosedMessage, Context.ConnectionAborted);
|
||||
}
|
||||
catch (SocialValidationException exception)
|
||||
{
|
||||
throw new HubException(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CancelPlayInvite(string inviteId)
|
||||
{
|
||||
var sender = RequireCurrentUser();
|
||||
|
||||
try
|
||||
{
|
||||
var cancelled = _playInviteCoordinator.CancelInvite(inviteId, sender.Subject);
|
||||
await Clients.Users([cancelled.SenderSubject, cancelled.RecipientSubject])
|
||||
.SendAsync("PlayInviteClosed", cancelled.ClosedMessage, Context.ConnectionAborted);
|
||||
}
|
||||
catch (SocialValidationException exception)
|
||||
{
|
||||
throw new HubException(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private AuthenticatedSiteUser RequireCurrentUser()
|
||||
=> AuthenticatedSiteUserFactory.FromClaimsPrincipal(Context.User ?? new System.Security.Claims.ClaimsPrincipal())
|
||||
?? throw new HubException("La session utilisateur est incomplete.");
|
||||
|
||||
private async Task SendPresenceSnapshotAsync(string subject, CancellationToken cancellationToken)
|
||||
{
|
||||
var relevantSubjects = await _socialStore.GetRelevantPresenceSubjectsAsync(subject, cancellationToken);
|
||||
var onlineSubjects = relevantSubjects
|
||||
.Where(_tracker.IsOnline)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
await Clients.Caller.SendAsync(
|
||||
"PresenceSnapshot",
|
||||
new PresenceSnapshotMessage { OnlineSubjects = onlineSubjects },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private Task NotifyPresenceChangedAsync(IReadOnlyList<string> subjects, string changedSubject, bool isOnline)
|
||||
{
|
||||
var distinctSubjects = subjects
|
||||
.Where(subject => !string.IsNullOrWhiteSpace(subject))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
return distinctSubjects.Length == 0
|
||||
? Task.CompletedTask
|
||||
: Clients.Users(distinctSubjects).SendAsync(
|
||||
"PresenceChanged",
|
||||
new PresenceChangedMessage
|
||||
{
|
||||
Subject = changedSubject,
|
||||
IsOnline = isOnline,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user