♻️ No idea, but errors all gone

This commit is contained in:
2025-07-08 23:55:31 +08:00
parent 2c67472894
commit 63b2b989ba
74 changed files with 1551 additions and 1100 deletions

View File

@@ -25,6 +25,10 @@
<PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="MailKit" Version="4.11.0" />
<PackageReference Include="MimeKit" Version="4.11.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.6" />
<PackageReference Include="StackExchange.Redis" Version="2.8.41" />
</ItemGroup>

View File

@@ -0,0 +1,18 @@
using System.Globalization;
namespace DysonNetwork.Shared.Localization;
public abstract class CultureInfoService
{
public static void SetCultureInfo(Shared.Models.Account account)
{
SetCultureInfo(account.Language);
}
public static void SetCultureInfo(string? languageCode)
{
var info = new CultureInfo(languageCode ?? "en-us", false);
CultureInfo.CurrentCulture = info;
CultureInfo.CurrentUICulture = info;
}
}

View File

@@ -0,0 +1,5 @@
namespace DysonNetwork.Shared.Localization;
public class EmailResource
{
}

View File

@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using NodaTime;
namespace DysonNetwork.Shared.Models;
public enum AbuseReportType
{
Copyright,
Harassment,
Impersonation,
OffensiveContent,
Spam,
PrivacyViolation,
IllegalContent,
Other
}
public class AbuseReport : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!;
public AbuseReportType Type { get; set; }
[MaxLength(8192)] public string Reason { get; set; } = null!;
public Instant? ResolvedAt { get; set; }
[MaxLength(8192)] public string? Resolution { get; set; }
public Guid AccountId { get; set; }
public Shared.Models.Account Account { get; set; } = null!;
}

View File

@@ -5,10 +5,14 @@ using Microsoft.Extensions.DependencyInjection;
using DysonNetwork.Shared.Models;
using System.Threading.Tasks;
using System;
using System.Reflection;
using Grpc.Core;
using MagicOnion;
using MagicOnion.Server.Hubs;
namespace DysonNetwork.Shared.Permission;
public class MagicOnionPermissionFilter : IMagicOnionFilter<RequiredPermissionAttribute>
public class MagicOnionPermissionFilter : IMagicOnionServiceFilter
{
private readonly IPermissionService _permissionService;
@@ -17,9 +21,20 @@ public class MagicOnionPermissionFilter : IMagicOnionFilter<RequiredPermissionAt
_permissionService = permissionService;
}
public async ValueTask Invoke(ServiceContext context, RequiredPermissionAttribute attribute, Func<ServiceContext, ValueTask> next)
public async ValueTask Invoke(ServiceContext context, Func<ServiceContext, ValueTask> next)
{
var httpContext = context.GetHttpContext();
var attribute = context.MethodInfo.GetCustomAttribute<RequiredPermissionAttribute>();
if (attribute == null)
{
// If no RequiredPermissionAttribute is present, just continue
await next(context);
return;
}
// Correct way to get HttpContext from ServiceContext
var httpContext = context.CallContext.GetHttpContext();
if (httpContext == null)
{
throw new InvalidOperationException("HttpContext is not available in ServiceContext.");
@@ -27,7 +42,7 @@ public class MagicOnionPermissionFilter : IMagicOnionFilter<RequiredPermissionAt
if (httpContext.Items["CurrentUser"] is not Account currentUser)
{
throw new ReturnStatusException(MagicOnion.Grpc.StatusCode.PermissionDenied, "Unauthorized: Current user not found.");
throw new ReturnStatusException(StatusCode.PermissionDenied, "Unauthorized: Current user not found.");
}
if (currentUser.IsSuperuser)
@@ -36,12 +51,11 @@ public class MagicOnionPermissionFilter : IMagicOnionFilter<RequiredPermissionAt
return;
}
var actor = $"user:{currentUser.Id}";
var hasPermission = await _permissionService.CheckPermission(actor, attribute.Scope, attribute.Permission);
var hasPermission = await _permissionService.CheckPermission(attribute.Scope, attribute.Permission);
if (!hasPermission)
{
throw new ReturnStatusException(MagicOnion.Grpc.StatusCode.PermissionDenied, $"Permission {attribute.Scope}/{attribute.Permission} was required.");
throw new ReturnStatusException(StatusCode.PermissionDenied, $"Permission {attribute.Scope}/{attribute.Permission} was required.");
}
await next(context);

View File

@@ -1,18 +1,16 @@
using System;
using MagicOnion.Server.Filters;
namespace DysonNetwork.Shared.Permission;
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = true, AllowMultiple = true)]
public class RequiredPermissionAttribute : MagicOnionFilterAttribute
public class RequiredPermissionAttribute : Attribute
{
public string Scope { get; }
public string Permission { get; }
public RequiredPermissionAttribute(string scope, string permission) : base(typeof(MagicOnionPermissionFilter))
public RequiredPermissionAttribute(string scope, string permission)
{
Scope = scope;
Permission = permission;
Order = 999; // Ensure this runs after authentication filters
}
}

View File

@@ -0,0 +1,78 @@
using MailKit.Net.Smtp;
using MimeKit;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
using System.Collections.Generic;
namespace DysonNetwork.Shared.Services;
public class EmailService
{
private readonly IConfiguration _configuration;
private readonly ILogger<EmailService> _logger;
public EmailService(IConfiguration configuration, ILogger<EmailService> logger)
{
_configuration = configuration;
_logger = logger;
}
public async Task SendEmailAsync(
string toName,
string toEmail,
string subject,
string body,
Dictionary<string, string>? headers = null
)
{
var message = new MimeMessage();
message.From.Add(new MailboxAddress(
_configuration["Email:SenderName"],
_configuration["Email:SenderEmail"]
));
message.To.Add(new MailboxAddress(toName, toEmail));
message.Subject = subject;
var bodyBuilder = new BodyBuilder { HtmlBody = body };
message.Body = bodyBuilder.ToMessageBody();
if (headers != null)
{
foreach (var header in headers)
{
message.Headers.Add(header.Key, header.Value);
}
}
using var client = new SmtpClient();
try
{
await client.ConnectAsync(
_configuration["Email:SmtpHost"],
int.Parse(_configuration["Email:SmtpPort"]),
MailKit.Security.SecureSocketOptions.StartTls
);
await client.AuthenticateAsync(
_configuration["Email:SmtpUser"],
_configuration["Email:SmtpPass"]
);
await client.SendAsync(message);
_logger.LogInformation("Email sent to {ToEmail}", toEmail);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email to {ToEmail}", toEmail);
throw;
}
finally
{
await client.DisconnectAsync(true);
}
}
public async Task SendTemplatedEmailAsync<T>(string toName, string toEmail, string subject, string htmlBody)
{
await SendEmailAsync(toName, toEmail, subject, htmlBody);
}
}

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using DysonNetwork.Shared.Models;
using MagicOnion;
using NodaTime;
@@ -10,17 +11,22 @@ public interface IAccountEventService : IService<IAccountEventService>
/// Purges the status cache for a user
/// </summary>
void PurgeStatusCache(Guid userId);
/// <summary>
/// Gets the status of a user
/// </summary>
Task<Status> GetStatus(Guid userId);
/// <summary>
/// Gets the statuses of a list of users
/// </summary>
Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> userIds);
/// <summary>
/// Performs a daily check-in for a user
/// </summary>
Task<CheckInResult> CheckInDaily(Account user);
/// <summary>
/// Gets the check-in streak for a user
/// </summary>

View File

@@ -0,0 +1,54 @@
using DysonNetwork.Shared.Models;
using MagicOnion;
using System;
using System.Threading.Tasks;
namespace DysonNetwork.Shared.Services
{
public interface IAccountProfileService : IService<IAccountProfileService>
{
/// <summary>
/// Gets an account profile by account ID.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <returns>The account profile if found, otherwise null.</returns>
Task<Profile?> GetAccountProfileByIdAsync(Guid accountId);
/// <summary>
/// Updates the StellarMembership of an account.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="subscription">The subscription to set as the StellarMembership.</param>
/// <returns>The updated account profile.</returns>
Task<Profile> UpdateStellarMembershipAsync(Guid accountId, SubscriptionReferenceObject? subscription);
/// <summary>
/// Gets all account profiles that have a non-null StellarMembership.
/// </summary>
/// <returns>A list of account profiles with StellarMembership.</returns>
Task<List<Profile>> GetAccountsWithStellarMembershipAsync();
/// <summary>
/// Clears the StellarMembership for a list of account IDs.
/// </summary>
/// <param name="accountIds">The list of account IDs for which to clear the StellarMembership.</param>
/// <returns>The number of accounts updated.</returns>
Task<int> ClearStellarMembershipsAsync(List<Guid> accountIds);
/// <summary>
/// Updates the profile picture of an account.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="picture">The new profile picture reference object.</param>
/// <returns>The updated profile.</returns>
Task<Profile> UpdateProfilePictureAsync(Guid accountId, CloudFileReferenceObject? picture);
/// <summary>
/// Updates the profile background of an account.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="background">The new profile background reference object.</param>
/// <returns>The updated profile.</returns>
Task<Profile> UpdateProfileBackgroundAsync(Guid accountId, CloudFileReferenceObject? background);
}
}

View File

@@ -1,5 +1,8 @@
using DysonNetwork.Shared.Models;
using MagicOnion;
using System.Collections.Generic;
using System.Threading.Tasks;
using System;
namespace DysonNetwork.Shared.Services;
@@ -59,4 +62,247 @@ public interface IAccountService : IService<IAccountService>
/// <param name="userInfo">The OpenID Connect user information</param>
/// <returns>The newly created account</returns>
Task<Account> CreateAccount(OidcUserInfo userInfo);
}
/// <summary>
/// Gets an account by its ID.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="withProfile">Join the profile table or not.</param>
/// <returns>The account if found, otherwise null.</returns>
Task<Account?> GetAccountById(Guid accountId, bool withProfile = false);
/// <summary>
/// Gets an account profile by account ID.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <returns>The account profile if found, otherwise null.</returns>
Task<Profile?> GetAccountProfile(Guid accountId);
/// <summary>
/// Gets an authentication challenge by its ID.
/// </summary>
/// <param name="challengeId">The ID of the challenge.</param>
/// <returns>The authentication challenge if found, otherwise null.</returns>
Task<Challenge?> GetAuthChallenge(Guid challengeId);
/// <summary>
/// Gets an authentication challenge by account ID, IP address, and user agent.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="ipAddress">The IP address.</param>
/// <param name="userAgent">The user agent.</param>
/// <param name="now">The current instant.</param>
/// <returns>The authentication challenge if found, otherwise null.</returns>
Task<Challenge?> GetAuthChallenge(Guid accountId, string? ipAddress, string? userAgent, NodaTime.Instant now);
/// <summary>
/// Creates a new authentication challenge.
/// </summary>
/// <param name="challenge">The challenge to create.</param>
/// <returns>The created challenge.</returns>
Task<Challenge> CreateAuthChallenge(Challenge challenge);
/// <summary>
/// Gets an account authentication factor by its ID and account ID.
/// </summary>
/// <param name="factorId">The ID of the factor.</param>
/// <param name="accountId">The ID of the account.</param>
/// <returns>The account authentication factor if found, otherwise null.</returns>
Task<AccountAuthFactor?> GetAccountAuthFactor(Guid factorId, Guid accountId);
/// <summary>
/// Gets a list of account authentication factors for a given account ID.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <returns>A list of account authentication factors.</returns>
Task<List<AccountAuthFactor>> GetAccountAuthFactors(Guid accountId);
/// <summary>
/// Gets an authentication session by its ID.
/// </summary>
/// <param name="sessionId">The ID of the session.</param>
/// <returns>The authentication session if found, otherwise null.</returns>
Task<Session?> GetAuthSession(Guid sessionId);
/// <summary>
/// Gets a magic spell by its ID.
/// </summary>
/// <param name="spellId">The ID of the magic spell.</param>
/// <returns>The magic spell if found, otherwise null.</returns>
Task<MagicSpell?> GetMagicSpell(Guid spellId);
/// <summary>
/// Gets an abuse report by its ID.
/// </summary>
/// <param name="reportId">The ID of the abuse report.</param>
/// <returns>The abuse report if found, otherwise null.</returns>
Task<AbuseReport?> GetAbuseReport(Guid reportId);
/// <summary>
/// Creates a new abuse report.
/// </summary>
/// <param name="resourceIdentifier">The identifier of the resource being reported.</param>
/// <param name="type">The type of abuse report.</param>
/// <param name="reason">The reason for the report.</param>
/// <param name="accountId">The ID of the account making the report.</param>
/// <returns>The created abuse report.</returns>
Task<AbuseReport> CreateAbuseReport(string resourceIdentifier, AbuseReportType type, string reason, Guid accountId);
/// <summary>
/// Counts abuse reports.
/// </summary>
/// <param name="includeResolved">Whether to include resolved reports.</param>
/// <returns>The count of abuse reports.</returns>
Task<int> CountAbuseReports(bool includeResolved = false);
/// <summary>
/// Counts abuse reports by a specific user.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="includeResolved">Whether to include resolved reports.</param>
/// <returns>The count of abuse reports by the user.</returns>
Task<int> CountUserAbuseReports(Guid accountId, bool includeResolved = false);
/// <summary>
/// Gets a list of abuse reports.
/// </summary>
/// <param name="skip">Number of reports to skip.</param>
/// <param name="take">Number of reports to take.</param>
/// <param name="includeResolved">Whether to include resolved reports.</param>
/// <returns>A list of abuse reports.</returns>
Task<List<AbuseReport>> GetAbuseReports(int skip = 0, int take = 20, bool includeResolved = false);
/// <summary>
/// Gets a list of abuse reports by a specific user.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="skip">Number of reports to skip.</param>
/// <param name="take">Number of reports to take.</param>
/// <param name="includeResolved">Whether to include resolved reports.</param>
/// <returns>A list of abuse reports by the user.</returns>
Task<List<AbuseReport>> GetUserAbuseReports(Guid accountId, int skip = 0, int take = 20, bool includeResolved = false);
/// <summary>
/// Resolves an abuse report.
/// </summary>
/// <param name="id">The ID of the report to resolve.</param>
/// <param name="resolution">The resolution message.</param>
/// <returns>The resolved abuse report.</returns>
Task<AbuseReport> ResolveAbuseReport(Guid id, string resolution);
/// <summary>
/// Gets the count of pending abuse reports.
/// </summary>
/// <returns>The count of pending abuse reports.</returns>
Task<int> GetPendingAbuseReportsCount();
/// <summary>
/// Checks if a relationship with a specific status exists between two accounts.
/// </summary>
/// <param name="accountId1">The ID of the first account.</param>
/// <param name="accountId2">The ID of the second account.</param>
/// <param name="status">The relationship status to check for.</param>
/// <returns>True if the relationship exists, otherwise false.</returns>
Task<bool> HasRelationshipWithStatus(Guid accountId1, Guid accountId2, RelationshipStatus status);
/// <summary>
/// Gets the statuses for a list of account IDs.
/// </summary>
/// <param name="accountIds">A list of account IDs.</param>
/// <returns>A dictionary where the key is the account ID and the value is the status.</returns>
Task<Dictionary<Guid, Status>> GetStatuses(List<Guid> accountIds);
/// <summary>
/// Sends a notification to an account.
/// </summary>
/// <param name="account">The target account.</param>
/// <param name="topic">The notification topic.</param>
/// <param name="title">The notification title.</param>
/// <param name="subtitle">The notification subtitle.</param>
/// <param name="body">The notification body.</param>
/// <param name="actionUri">The action URI for the notification.</param>
/// <returns>A task representing the asynchronous operation.</returns>
Task SendNotification(Account account, string topic, string title, string? subtitle, string body, string? actionUri = null);
/// <summary>
/// Lists the friends of an account.
/// </summary>
/// <param name="account">The account.</param>
/// <returns>A list of friend accounts.</returns>
Task<List<Account>> ListAccountFriends(Account account);
/// <summary>
/// Verifies an authentication factor code.
/// </summary>
/// <param name="factor">The authentication factor.</param>
/// <param name="code">The code to verify.</param>
/// <returns>True if the code is valid, otherwise false.</returns>
Task<bool> VerifyFactorCode(AccountAuthFactor factor, string code);
/// <summary>
/// Send the auth factor verification code to users, for factors like in-app code and email.
/// Sometimes it requires a hint, like a part of the user's email address to ensure the user is who own the account.
/// </summary>
/// <param name="account">The owner of the auth factor</param>
/// <param name="factor">The auth factor needed to send code</param>
/// <param name="hint">The part of the contact method for verification</param>
Task SendFactorCode(Account account, AccountAuthFactor factor, string? hint = null);
/// <summary>
/// Creates an action log entry.
/// </summary>
/// <param name="type">The type of action log.</param>
/// <param name="meta">Additional metadata for the action log.</param>
/// <param name="request">The HTTP request.</param>
/// <param name="account">The account associated with the action.</param>
/// <returns>The created action log.</returns>
Task<ActionLog> CreateActionLogFromRequest(string type, Dictionary<string, object> meta, string? ipAddress, string? userAgent, Account? account = null);
/// <summary>
/// Creates a new session.
/// </summary>
/// <param name="lastGrantedAt">The last granted instant.</param>
/// <param name="expiredAt">The expiration instant.</param>
/// <param name="account">The associated account.</param>
/// <param name="challenge">The associated challenge.</param>
/// <returns>The created session.</returns>
Task<Session> CreateSession(NodaTime.Instant lastGrantedAt, NodaTime.Instant expiredAt, Account account, Challenge challenge);
/// <summary>
/// Updates the LastGrantedAt for a session.
/// </summary>
/// <param name="sessionId">The ID of the session.</param>
/// <param name="lastGrantedAt">The new LastGrantedAt instant.</param>
Task UpdateSessionLastGrantedAt(Guid sessionId, NodaTime.Instant lastGrantedAt);
/// <summary>
/// Updates the LastSeenAt for an account profile.
/// </summary>
/// <param name="accountId">The ID of the account.</param>
/// <param name="lastSeenAt">The new LastSeenAt instant.</param>
Task UpdateAccountProfileLastSeenAt(Guid accountId, NodaTime.Instant lastSeenAt);
/// <summary>
/// Creates a token for a session.
/// </summary>
/// <param name="session">The session.</param>
/// <returns>The token string.</returns>
string CreateToken(Session session);
/// <summary>
/// Gets the AuthConstants.CookieTokenName.
/// </summary>
/// <returns>The cookie token name.</returns>
string GetAuthCookieTokenName();
/// <summary>
/// Searches for accounts by a search term.
/// </summary>
/// <param name="searchTerm">The term to search for.</param>
/// <returns>A list of matching accounts.</returns>
Task<List<Account>> SearchAccountsAsync(string searchTerm);
}

View File

@@ -1,7 +1,6 @@
using System.Collections.Generic;
using DysonNetwork.Shared.Models;
using MagicOnion;
using Microsoft.AspNetCore.Http;
namespace DysonNetwork.Shared.Services;
@@ -15,10 +14,11 @@ public interface IActionLogService : IService<IActionLogService>
/// <summary>
/// Creates an action log entry from an HTTP request
/// </summary>
void CreateActionLogFromRequest(
string action,
Task<ActionLog> CreateActionLogFromRequest(
string type,
Dictionary<string, object> meta,
HttpRequest request,
string? ipAddress,
string? userAgent,
Account? account = null
);
}
}

View File

@@ -0,0 +1,13 @@
using MagicOnion;
using DysonNetwork.Shared.Models;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace DysonNetwork.Shared.Services;
public interface ICustomAppService : IService<ICustomAppService>
{
Task<CustomApp?> FindClientByIdAsync(Guid clientId);
Task<int> CountCustomAppsByPublisherId(Guid publisherId);
}

View File

@@ -22,7 +22,21 @@ public interface IMagicSpellService : IService<IMagicSpellService>
/// Gets a magic spell by its token
/// </summary>
Task<MagicSpell?> GetMagicSpellAsync(string token);
/// <summary>
/// Gets a magic spell by its ID.
/// </summary>
/// <param name="spellId">The ID of the magic spell.</param>
/// <returns>The magic spell if found, otherwise null.</returns>
Task<MagicSpell?> GetMagicSpellByIdAsync(Guid spellId);
/// <summary>
/// Applies a password reset magic spell.
/// </summary>
/// <param name="spell">The magic spell object.</param>
/// <param name="newPassword">The new password.</param>
Task ApplyPasswordReset(MagicSpell spell, string newPassword);
/// <summary>
/// Consumes a magic spell
/// </summary>

View File

@@ -1,5 +1,7 @@
using DysonNetwork.Shared.Models;
using MagicOnion;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace DysonNetwork.Shared.Services;
@@ -20,4 +22,25 @@ public interface INotificationService : IService<INotificationService>
string deviceId,
string deviceToken
);
Task<Notification> SendNotification(
Account account,
string topic,
string? title = null,
string? subtitle = null,
string? content = null,
Dictionary<string, object>? meta = null,
string? actionUri = null,
bool isSilent = false,
bool save = true
);
Task DeliveryNotification(Notification notification);
Task MarkNotificationsViewed(ICollection<Notification> notifications);
Task BroadcastNotification(Notification notification, bool save = false);
Task SendNotificationBatch(Notification notification, List<Account> accounts,
bool save = false);
}

View File

@@ -0,0 +1,15 @@
using MagicOnion;
using DysonNetwork.Shared.Models;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace DysonNetwork.Shared.Services;
public interface IPublisherService : IService<IPublisherService>
{
Task<Publisher?> GetPublisherByName(string name);
Task<List<Publisher>> GetUserPublishers(Guid accountId);
Task<bool> IsMemberWithRole(Guid publisherId, Guid accountId, PublisherMemberRole role);
Task<List<PublisherFeature>> GetPublisherFeatures(Guid publisherId);
}

View File

@@ -1,3 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using DysonNetwork.Shared.Models;
using MagicOnion;
@@ -9,7 +12,7 @@ public interface IRelationshipService : IService<IRelationshipService>
/// Checks if a relationship exists between two accounts
/// </summary>
Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId);
/// <summary>
/// Gets a relationship between two accounts
/// </summary>
@@ -19,9 +22,58 @@ public interface IRelationshipService : IService<IRelationshipService>
RelationshipStatus? status = null,
bool ignoreExpired = false
);
/// <summary>
/// Creates a new relationship between two accounts
/// </summary>
Task<Relationship> CreateRelationship(Account sender, Account target, RelationshipStatus status);
/// <summary>
/// Blocks a user
/// </summary>
Task<Relationship> BlockAccount(Account sender, Account target);
/// <summary>
/// Unblocks a user
/// </summary>
Task<Relationship> UnblockAccount(Account sender, Account target);
/// <summary>
/// Sends a friend request to a user
/// </summary>
Task<Relationship> SendFriendRequest(Account sender, Account target);
/// <summary>
/// Deletes a friend request
/// </summary>
Task DeleteFriendRequest(Guid accountId, Guid relatedId);
/// <summary>
/// Accepts a friend request
/// </summary>
Task<Relationship> AcceptFriendRelationship(
Relationship relationship,
RelationshipStatus status = RelationshipStatus.Friends
);
/// <summary>
/// Updates a relationship between two users
/// </summary>
Task<Relationship> UpdateRelationship(Guid accountId, Guid relatedId, RelationshipStatus status);
/// <summary>
/// Lists all friends of an account
/// </summary>
Task<List<Account>> ListAccountFriends(Account account);
/// <summary>
/// Lists all blocked users of an account
/// </summary>
Task<List<Guid>> ListAccountBlocked(Account account);
/// <summary>
/// Checks if a relationship with a specific status exists between two accounts
/// </summary>
Task<bool> HasRelationshipWithStatus(Guid accountId, Guid relatedId,
RelationshipStatus status = RelationshipStatus.Friends);
}