Activity-based browsing

This commit is contained in:
LittleSheep 2025-05-01 14:59:28 +08:00
parent 42b5129aa4
commit bf64afd849
12 changed files with 354 additions and 189 deletions

View File

@ -10,6 +10,8 @@ public class AccountService(AppDatabase db, PermissionService pm, IMemoryCache c
{
public async Task PurgeAccountCache(Account account)
{
cache.Remove($"dyn_user_friends_{account.Id}");
var sessions = await db.AuthSessions.Where(e => e.Account.Id == account.Id).Select(e => e.Id)
.ToListAsync();
foreach (var session in sessions)
@ -31,144 +33,4 @@ public class AccountService(AppDatabase db, PermissionService pm, IMemoryCache c
return null;
}
public async Task<bool> HasExistingRelationship(Account userA, Account userB)
{
var count = await db.AccountRelationships
.Where(r => (r.AccountId == userA.Id && r.AccountId == userB.Id) ||
(r.AccountId == userB.Id && r.AccountId == userA.Id))
.CountAsync();
return count > 0;
}
public async Task<Relationship?> GetRelationship(
Account account,
Account related,
RelationshipStatus? status,
bool ignoreExpired = false
)
{
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
var queries = db.AccountRelationships
.Where(r => r.AccountId == account.Id && r.AccountId == related.Id);
if (ignoreExpired) queries = queries.Where(r => r.ExpiredAt > now);
if (status is not null) queries = queries.Where(r => r.Status == status);
var relationship = await queries.FirstOrDefaultAsync();
return relationship;
}
public async Task<Relationship> CreateRelationship(Account sender, Account target, RelationshipStatus status)
{
if (status == RelationshipStatus.Pending)
throw new InvalidOperationException(
"Cannot create relationship with pending status, use SendFriendRequest instead.");
if (await HasExistingRelationship(sender, target))
throw new InvalidOperationException("Found existing relationship between you and target user.");
var relationship = new Relationship
{
Account = sender,
AccountId = sender.Id,
Related = target,
RelatedId = target.Id,
Status = status
};
db.AccountRelationships.Add(relationship);
await db.SaveChangesAsync();
await ApplyRelationshipPermissions(relationship);
return relationship;
}
public async Task<Relationship> SendFriendRequest(Account sender, Account target)
{
if (await HasExistingRelationship(sender, target))
throw new InvalidOperationException("Found existing relationship between you and target user.");
var relationship = new Relationship
{
Account = sender,
AccountId = sender.Id,
Related = target,
RelatedId = target.Id,
Status = RelationshipStatus.Pending,
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(7))
};
db.AccountRelationships.Add(relationship);
await db.SaveChangesAsync();
return relationship;
}
public async Task<Relationship> AcceptFriendRelationship(
Relationship relationship,
RelationshipStatus status = RelationshipStatus.Friends
)
{
if (relationship.Status == RelationshipStatus.Pending)
throw new ArgumentException("Cannot accept friend request by setting the new status to pending.");
// Whatever the receiver decides to apply which status to the relationship,
// the sender should always see the user as a friend since the sender ask for it
relationship.Status = RelationshipStatus.Friends;
relationship.ExpiredAt = null;
db.Update(relationship);
var relationshipBackward = new Relationship
{
Account = relationship.Related,
AccountId = relationship.RelatedId,
Related = relationship.Account,
RelatedId = relationship.AccountId,
Status = status
};
db.AccountRelationships.Add(relationshipBackward);
await db.SaveChangesAsync();
await Task.WhenAll(
ApplyRelationshipPermissions(relationship),
ApplyRelationshipPermissions(relationshipBackward)
);
return relationshipBackward;
}
public async Task<Relationship> UpdateRelationship(Account account, Account related, RelationshipStatus status)
{
var relationship = await GetRelationship(account, related, status);
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
if (relationship.Status == status) return relationship;
relationship.Status = status;
db.Update(relationship);
await db.SaveChangesAsync();
await ApplyRelationshipPermissions(relationship);
return relationship;
}
private async Task ApplyRelationshipPermissions(Relationship relationship)
{
// Apply the relationship permissions to casbin enforcer
// domain: the user
// status is friends: all permissions are allowed by default, expect specially specified
// status is blocked: all permissions are disallowed by default, expect specially specified
// others: use the default permissions by design
var domain = $"user:{relationship.AccountId.ToString()}";
var target = $"user:{relationship.RelatedId.ToString()}";
await pm.RemovePermissionNode(target, domain, "*");
bool? value = relationship.Status switch
{
RelationshipStatus.Friends => true,
RelationshipStatus.Blocked => false,
_ => null,
};
if (value is null) return;
await pm.AddPermissionNode(target, domain, "*", value);
}
}

View File

@ -7,7 +7,7 @@ namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/relationships")]
public class RelationshipController(AppDatabase db, AccountService accounts) : ControllerBase
public class RelationshipController(AppDatabase db, RelationshipService rels) : ControllerBase
{
[HttpGet]
[Authorize]
@ -48,7 +48,7 @@ public class RelationshipController(AppDatabase db, AccountService accounts) : C
try
{
var relationship = await accounts.CreateRelationship(
var relationship = await rels.CreateRelationship(
currentUser, relatedUser, request.Status
);
return relationship;

View File

@ -0,0 +1,171 @@
using DysonNetwork.Sphere.Permission;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class RelationshipService(AppDatabase db, PermissionService pm, IMemoryCache cache)
{
public async Task<bool> HasExistingRelationship(Account userA, Account userB)
{
var count = await db.AccountRelationships
.Where(r => (r.AccountId == userA.Id && r.AccountId == userB.Id) ||
(r.AccountId == userB.Id && r.AccountId == userA.Id))
.CountAsync();
return count > 0;
}
public async Task<Relationship?> GetRelationship(
Account account,
Account related,
RelationshipStatus? status,
bool ignoreExpired = false
)
{
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
var queries = db.AccountRelationships
.Where(r => r.AccountId == account.Id && r.AccountId == related.Id);
if (ignoreExpired) queries = queries.Where(r => r.ExpiredAt > now);
if (status is not null) queries = queries.Where(r => r.Status == status);
var relationship = await queries.FirstOrDefaultAsync();
return relationship;
}
public async Task<Relationship> CreateRelationship(Account sender, Account target, RelationshipStatus status)
{
if (status == RelationshipStatus.Pending)
throw new InvalidOperationException(
"Cannot create relationship with pending status, use SendFriendRequest instead.");
if (await HasExistingRelationship(sender, target))
throw new InvalidOperationException("Found existing relationship between you and target user.");
var relationship = new Relationship
{
Account = sender,
AccountId = sender.Id,
Related = target,
RelatedId = target.Id,
Status = status
};
db.AccountRelationships.Add(relationship);
await db.SaveChangesAsync();
await ApplyRelationshipPermissions(relationship);
cache.Remove($"dyn_user_friends_{relationship.AccountId}");
cache.Remove($"dyn_user_friends_{relationship.RelatedId}");
return relationship;
}
public async Task<Relationship> SendFriendRequest(Account sender, Account target)
{
if (await HasExistingRelationship(sender, target))
throw new InvalidOperationException("Found existing relationship between you and target user.");
var relationship = new Relationship
{
Account = sender,
AccountId = sender.Id,
Related = target,
RelatedId = target.Id,
Status = RelationshipStatus.Pending,
ExpiredAt = Instant.FromDateTimeUtc(DateTime.UtcNow.AddDays(7))
};
db.AccountRelationships.Add(relationship);
await db.SaveChangesAsync();
return relationship;
}
public async Task<Relationship> AcceptFriendRelationship(
Relationship relationship,
RelationshipStatus status = RelationshipStatus.Friends
)
{
if (relationship.Status == RelationshipStatus.Pending)
throw new ArgumentException("Cannot accept friend request by setting the new status to pending.");
// Whatever the receiver decides to apply which status to the relationship,
// the sender should always see the user as a friend since the sender ask for it
relationship.Status = RelationshipStatus.Friends;
relationship.ExpiredAt = null;
db.Update(relationship);
var relationshipBackward = new Relationship
{
Account = relationship.Related,
AccountId = relationship.RelatedId,
Related = relationship.Account,
RelatedId = relationship.AccountId,
Status = status
};
db.AccountRelationships.Add(relationshipBackward);
await db.SaveChangesAsync();
await Task.WhenAll(
ApplyRelationshipPermissions(relationship),
ApplyRelationshipPermissions(relationshipBackward)
);
cache.Remove($"dyn_user_friends_{relationship.AccountId}");
cache.Remove($"dyn_user_friends_{relationship.RelatedId}");
return relationshipBackward;
}
public async Task<Relationship> UpdateRelationship(Account account, Account related, RelationshipStatus status)
{
var relationship = await GetRelationship(account, related, status);
if (relationship is null) throw new ArgumentException("There is no relationship between you and the user.");
if (relationship.Status == status) return relationship;
relationship.Status = status;
db.Update(relationship);
await db.SaveChangesAsync();
await ApplyRelationshipPermissions(relationship);
cache.Remove($"dyn_user_friends_{related.Id}");
return relationship;
}
public async Task<List<long>> ListAccountFriends(Account account)
{
if (!cache.TryGetValue($"dyn_user_friends_{account.Id}", out List<long>? friends))
{
friends = await db.AccountRelationships
.Where(r => r.RelatedId == account.Id)
.Where(r => r.Status == RelationshipStatus.Friends)
.Select(r => r.AccountId)
.ToListAsync();
cache.Set($"dyn_user_friends_{account.Id}", friends, TimeSpan.FromHours(1));
}
return friends ?? [];
}
private async Task ApplyRelationshipPermissions(Relationship relationship)
{
// Apply the relationship permissions to casbin enforcer
// domain: the user
// status is friends: all permissions are allowed by default, expect specially specified
// status is blocked: all permissions are disallowed by default, expect specially specified
// others: use the default permissions by design
var domain = $"user:{relationship.AccountId.ToString()}";
var target = $"user:{relationship.RelatedId.ToString()}";
await pm.RemovePermissionNode(target, domain, "*");
bool? value = relationship.Status switch
{
RelationshipStatus.Friends => true,
RelationshipStatus.Blocked => false,
_ => null,
};
if (value is null) return;
await pm.AddPermissionNode(target, domain, "*", value);
}
}

View File

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace DysonNetwork.Sphere.Activity;
public enum ActivityVisibility
{
Public,
Friends,
}
public class Activity : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Type { get; set; } = null!;
[MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!;
public ActivityVisibility Visibility { get; set; } = ActivityVisibility.Public;
public Dictionary<string, object> Meta = new();
public long AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
}

View File

@ -0,0 +1,33 @@
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Activity;
[ApiController]
[Route("/activities")]
public class ActivityController(AppDatabase db, ActivityService act, RelationshipService rels) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<List<Activity>>> ListActivities([FromQuery] int offset, [FromQuery] int take = 20)
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account.Account;
var userFriends = await rels.ListAccountFriends(currentUser!);
var totalCount = await db.Activities
.FilterWithVisibility(currentUser, userFriends)
.CountAsync();
var posts = await db.Activities
.Include(e => e.Account)
.FilterWithVisibility(currentUser, userFriends)
.OrderByDescending(e => e.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
Response.Headers["X-Total"] = totalCount.ToString();
return Ok(posts);
}
}

View File

@ -0,0 +1,54 @@
using DysonNetwork.Sphere.Post;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
public class ActivityService(AppDatabase db)
{
public async Task<Activity> CreateActivity(
Account.Account user,
string type,
string identifier,
ActivityVisibility visibility = ActivityVisibility.Public
)
{
var activity = new Activity
{
Type = type,
ResourceIdentifier = identifier,
Visibility = visibility,
AccountId = user.Id,
};
db.Activities.Add(activity);
await db.SaveChangesAsync();
return activity;
}
public async Task CreateNewPostActivity(Account.Account user, Post.Post post)
{
if (post.Visibility is PostVisibility.Unlisted or PostVisibility.Private) return;
var identifier = $"posts/{post.Id}";
await CreateActivity(user, "posts.new", identifier,
post.Visibility == PostVisibility.Friends ? ActivityVisibility.Friends : ActivityVisibility.Public);
}
}
public static class ActivityQueryExtensions
{
public static IQueryable<Activity> FilterWithVisibility(this IQueryable<Activity> source,
Account.Account? currentUser, List<long> userFriends)
{
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
if (currentUser is null)
return source.Where(e => e.Visibility == ActivityVisibility.Public);
return source
.Where(e => e.Visibility != ActivityVisibility.Friends ||
userFriends.Contains(e.AccountId) ||
e.AccountId == currentUser.Id);
}
}

View File

@ -37,6 +37,8 @@ public class AppDatabase(
public DbSet<Auth.Challenge> AuthChallenges { get; set; }
public DbSet<Storage.CloudFile> Files { get; set; }
public DbSet<Activity.Activity> Activities { get; set; }
public DbSet<Post.Publisher> Publishers { get; set; }
public DbSet<Post.PublisherMember> PublisherMembers { get; set; }

View File

@ -8,13 +8,8 @@ namespace DysonNetwork.Sphere.Connection;
[ApiController]
[Route("/ws")]
public class WebSocketController(ILogger<WebSocketContext> logger) : ControllerBase
public class WebSocketController(WebSocketService ws, ILogger<WebSocketContext> logger) : ControllerBase
{
private static readonly ConcurrentDictionary<
(long AccountId, string DeviceId),
(WebSocket Socket, CancellationTokenSource Cts)
> ActiveConnections = new();
[Route("/ws")]
[Authorize]
[SwaggerIgnore]
@ -42,7 +37,7 @@ public class WebSocketController(ILogger<WebSocketContext> logger) : ControllerB
var cts = new CancellationTokenSource();
var connectionKey = (accountId, deviceId);
if (!ActiveConnections.TryAdd(connectionKey, (webSocket, cts)))
if (!ws.TryAdd(connectionKey, webSocket, cts))
{
await webSocket.CloseAsync(
WebSocketCloseStatus.InternalServerError,
@ -57,7 +52,7 @@ public class WebSocketController(ILogger<WebSocketContext> logger) : ControllerB
try
{
await _ConnectionEventLoop(webSocket, connectionKey, cts.Token);
await _ConnectionEventLoop(connectionKey, webSocket, cts.Token);
}
catch (Exception ex)
{
@ -65,16 +60,15 @@ public class WebSocketController(ILogger<WebSocketContext> logger) : ControllerB
}
finally
{
ActiveConnections.TryRemove(connectionKey, out _);
cts.Dispose();
ws.Disconnect(connectionKey);
logger.LogInformation(
$"Connection disconnected with user @{currentUser.Name}#{currentUser.Id} and device #{deviceId}");
}
}
private static async Task _ConnectionEventLoop(
WebSocket webSocket,
private async Task _ConnectionEventLoop(
(long AccountId, string DeviceId) connectionKey,
WebSocket webSocket,
CancellationToken cancellationToken
)
{
@ -93,41 +87,17 @@ public class WebSocketController(ILogger<WebSocketContext> logger) : ControllerB
);
}
await webSocket.CloseAsync(
receiveResult.CloseStatus.Value,
receiveResult.CloseStatusDescription,
cancellationToken
);
// TODO handle values
}
catch (OperationCanceledException)
{
// Connection was canceled, close it gracefully
if (
webSocket.State != WebSocketState.Closed
&& webSocket.State != WebSocketState.Aborted
)
{
await webSocket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Connection closed by server",
CancellationToken.None
);
ws.Disconnect(connectionKey);
}
}
}
// This method will be used later to send messages to specific connections
public static async Task SendMessageAsync(long accountId, string deviceId, string message)
{
if (ActiveConnections.TryGetValue((accountId, deviceId), out var connection))
{
var buffer = System.Text.Encoding.UTF8.GetBytes(message);
await connection.Socket.SendAsync(
new ArraySegment<byte>(buffer, 0, buffer.Length),
WebSocketMessageType.Text,
true,
connection.Cts.Token
);
}
}
}

View File

@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
namespace DysonNetwork.Sphere.Connection;
public class WebSocketService
{
public static readonly ConcurrentDictionary<
(long AccountId, string DeviceId),
(WebSocket Socket, CancellationTokenSource Cts)
> ActiveConnections = new();
public bool TryAdd(
(long AccountId, string DeviceId) key,
WebSocket socket,
CancellationTokenSource cts
)
{
if (ActiveConnections.TryGetValue(key, out _))
Disconnect(key, "Just connected somewhere else with the same identifier."); // Disconnect the previous one using the same identifier
return ActiveConnections.TryAdd(key, (socket, cts));
}
public void Disconnect((long AccountId, string DeviceId) key, string? reason = null)
{
if (!ActiveConnections.TryGetValue(key, out var data)) return;
data.Socket.CloseAsync(
WebSocketCloseStatus.NormalClosure,
reason ?? "Server just decided to disconnect.",
CancellationToken.None
);
data.Cts.Cancel();
ActiveConnections.TryRemove(key, out _);
}
}

View File

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using Casbin;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Permission;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -9,16 +10,17 @@ namespace DysonNetwork.Sphere.Post;
[ApiController]
[Route("/posts")]
public class PostController(AppDatabase db, PostService ps) : ControllerBase
public class PostController(AppDatabase db, PostService ps, RelationshipService rels) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<List<Post>>> ListPosts([FromQuery] int offset = 0, [FromQuery] int take = 20)
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account.Account;
var userFriends = await rels.ListAccountFriends(currentUser!);
var totalCount = await db.Posts
.FilterWithVisibility(currentUser, isListing: true)
.FilterWithVisibility(currentUser, userFriends, isListing: true)
.CountAsync();
var posts = await db.Posts
.Include(e => e.Publisher)
@ -30,7 +32,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
.Include(e => e.Categories)
.Include(e => e.Tags)
.Where(e => e.RepliedPostId == null)
.FilterWithVisibility(currentUser, isListing: true)
.FilterWithVisibility(currentUser, userFriends, isListing: true)
.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt)
.Skip(offset)
.Take(take)
@ -46,6 +48,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account.Account;
var userFriends = await rels.ListAccountFriends(currentUser!);
var post = await db.Posts
.Where(e => e.Id == id)
@ -58,7 +61,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
.Include(e => e.Tags)
.Include(e => e.Categories)
.Include(e => e.Attachments)
.FilterWithVisibility(currentUser)
.FilterWithVisibility(currentUser, userFriends)
.FirstOrDefaultAsync();
if (post is null) return NotFound();
@ -71,6 +74,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account.Account;
var userFriends = await rels.ListAccountFriends(currentUser!);
var post = await db.Posts
.Where(e => e.Id == id)
@ -79,7 +83,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
var totalCount = await db.Posts
.Where(e => e.RepliedPostId == post.Id)
.FilterWithVisibility(currentUser, isListing: true)
.FilterWithVisibility(currentUser, userFriends, isListing: true)
.CountAsync();
var posts = await db.Posts
.Where(e => e.RepliedPostId == id)
@ -91,7 +95,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
.Include(e => e.Attachments)
.Include(e => e.Categories)
.Include(e => e.Tags)
.FilterWithVisibility(currentUser, isListing: true)
.FilterWithVisibility(currentUser, userFriends, isListing: true)
.OrderByDescending(e => e.PublishedAt ?? e.CreatedAt)
.Skip(offset)
.Take(take)
@ -179,6 +183,7 @@ public class PostController(AppDatabase db, PostService ps) : ControllerBase
try
{
post = await ps.PostAsync(
currentUser,
post,
attachments: request.Attachments,
tags: request.Tags,

View File

@ -1,12 +1,14 @@
using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Post;
public class PostService(AppDatabase db, FileService fs)
public class PostService(AppDatabase db, FileService fs, ActivityService act)
{
public async Task<Post> PostAsync(
Account.Account user,
Post post,
List<string>? attachments = null,
List<string>? tags = null,
@ -66,6 +68,8 @@ public class PostService(AppDatabase db, FileService fs)
await db.SaveChangesAsync();
await fs.MarkUsageRangeAsync(post.Attachments, 1);
await act.CreateNewPostActivity(user, post);
return post;
}
@ -153,7 +157,7 @@ public class PostService(AppDatabase db, FileService fs)
public static class PostQueryExtensions
{
public static IQueryable<Post> FilterWithVisibility(this IQueryable<Post> source, Account.Account? currentUser,
bool isListing = false)
List<long> userFriends, bool isListing = false)
{
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
@ -172,6 +176,9 @@ public static class PostQueryExtensions
return source
.Where(e => e.PublishedAt != null && now >= e.PublishedAt && e.Publisher.AccountId == currentUser.Id)
.Where(e => e.Visibility != PostVisibility.Private || e.Publisher.AccountId == currentUser.Id);
.Where(e => e.Visibility != PostVisibility.Private || e.Publisher.AccountId == currentUser.Id)
.Where(e => e.Visibility != PostVisibility.Friends ||
(e.Publisher.AccountId != null && userFriends.Contains(e.Publisher.AccountId.Value)) ||
e.Publisher.AccountId == currentUser.Id);
}
}

View File

@ -7,7 +7,9 @@ using Casbin;
using Casbin.Persist.Adapter.EFCore;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Connection;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Storage;
@ -114,14 +116,17 @@ builder.Services.AddSwaggerGen(options =>
});
builder.Services.AddOpenApi();
builder.Services.AddScoped<WebSocketService>();
builder.Services.AddScoped<EmailService>();
builder.Services.AddScoped<PermissionService>();
builder.Services.AddScoped<AccountService>();
builder.Services.AddScoped<RelationshipService>();
builder.Services.AddScoped<MagicSpellService>();
builder.Services.AddScoped<NotificationService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<FileService>();
builder.Services.AddScoped<PublisherService>();
builder.Services.AddScoped<ActivityService>();
builder.Services.AddScoped<PostService>();
// Timed task