Add Ping backend

.NET 10 solution for Ping, a Discord style chat app: Domain, Application
and Infrastructure projects plus the Api host. SignalR for chat and
presence, LiveKit for call tokens, EF Core on Postgres, S3 compatible
storage for attachments, and OIDC auth shared with the Musify Zitadel
instance. Includes the Compose stack for local development.
This commit is contained in:
2026-09-21 19:32:55 +02:00
commit 0623875dfa
126 changed files with 5048 additions and 0 deletions
@@ -0,0 +1,40 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
using Ping.Application.Friends.Responses;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Friends
{
public record AcceptFriendRequestCommand(long CurrentUserId, Guid FriendshipId) : ICommand<ErrorOr<FriendshipResponse>>;
public class AcceptFriendRequestCommandHandler(IDatabase database, IRealtimeNotifier notifier)
: ICommandHandler<AcceptFriendRequestCommand, ErrorOr<FriendshipResponse>>
{
public async ValueTask<ErrorOr<FriendshipResponse>> Handle(AcceptFriendRequestCommand request, CancellationToken cancellationToken)
{
var friendship = await database.Friendships
.Include(f => f.Requester)
.Include(f => f.Addressee)
.FirstOrDefaultAsync(f => f.Id == request.FriendshipId, cancellationToken);
if (friendship is null)
return Error.NotFound(description: $"Friend request {request.FriendshipId} not found");
if (friendship.AddresseeId != request.CurrentUserId)
return Error.Forbidden(description: "Only the addressee can accept this friend request");
if (friendship.Status != FriendshipStatus.Pending)
return Error.Conflict(description: "This friend request is no longer pending");
friendship.Status = FriendshipStatus.Accepted;
friendship.RespondedAt = DateTime.UtcNow;
await database.SaveChangesAsync(cancellationToken);
await notifier.FriendshipUpdatedAsync(friendship.RequesterId, friendship.Id, cancellationToken);
return FriendshipResponse.FromEntity(friendship, request.CurrentUserId);
}
}
}
@@ -0,0 +1,46 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
using Ping.Domain.Entities;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Friends
{
public record BlockUserCommand(long CurrentUserId, long TargetUserId) : ICommand<ErrorOr<Success>>;
public class BlockUserCommandHandler(IDatabase database, IRealtimeNotifier notifier)
: ICommandHandler<BlockUserCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(BlockUserCommand request, CancellationToken cancellationToken)
{
if (request.CurrentUserId == request.TargetUserId)
return Error.Validation(description: "You cannot block yourself");
var friendship = await database.Friendships.FirstOrDefaultAsync(f =>
(f.RequesterId == request.CurrentUserId && f.AddresseeId == request.TargetUserId) ||
(f.RequesterId == request.TargetUserId && f.AddresseeId == request.CurrentUserId),
cancellationToken);
if (friendship is null)
{
friendship = new Friendship
{
Id = Guid.NewGuid(),
RequesterId = request.CurrentUserId,
AddresseeId = request.TargetUserId,
};
await database.Friendships.AddAsync(friendship, cancellationToken);
}
friendship.Status = FriendshipStatus.Blocked;
friendship.BlockedById = request.CurrentUserId;
friendship.RespondedAt = DateTime.UtcNow;
await database.SaveChangesAsync(cancellationToken);
await notifier.FriendshipUpdatedAsync(request.TargetUserId, friendship.Id, cancellationToken);
return new Success();
}
}
}
@@ -0,0 +1,30 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
using Ping.Application.Friends.Responses;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Friends
{
public record GetFriendsQuery(long UserId) : IQuery<IReadOnlyList<FriendshipResponse>>;
public class GetFriendsQueryHandler(IDatabase database, IPresenceTracker presence)
: IQueryHandler<GetFriendsQuery, IReadOnlyList<FriendshipResponse>>
{
public async ValueTask<IReadOnlyList<FriendshipResponse>> Handle(GetFriendsQuery request, CancellationToken cancellationToken)
{
var friendships = await database.Friendships.AsNoTracking()
.Include(f => f.Requester)
.Include(f => f.Addressee)
.Where(f => f.Status == FriendshipStatus.Accepted &&
(f.RequesterId == request.UserId || f.AddresseeId == request.UserId))
.ToListAsync(cancellationToken);
return friendships.Select(f =>
{
var otherUserId = f.RequesterId == request.UserId ? f.AddresseeId : f.RequesterId;
return FriendshipResponse.FromEntity(f, request.UserId, presence.GetStatus(otherUserId));
}).ToList();
}
}
}
@@ -0,0 +1,26 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
using Ping.Application.Friends.Responses;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Friends
{
public record GetPendingFriendRequestsQuery(long UserId) : IQuery<IReadOnlyList<FriendshipResponse>>;
public class GetPendingFriendRequestsQueryHandler(IDatabase database)
: IQueryHandler<GetPendingFriendRequestsQuery, IReadOnlyList<FriendshipResponse>>
{
public async ValueTask<IReadOnlyList<FriendshipResponse>> Handle(GetPendingFriendRequestsQuery request, CancellationToken cancellationToken)
{
var friendships = await database.Friendships.AsNoTracking()
.Include(f => f.Requester)
.Include(f => f.Addressee)
.Where(f => f.Status == FriendshipStatus.Pending &&
(f.RequesterId == request.UserId || f.AddresseeId == request.UserId))
.ToListAsync(cancellationToken);
return friendships.Select(f => FriendshipResponse.FromEntity(f, request.UserId)).ToList();
}
}
}
@@ -0,0 +1,35 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Friends
{
public record RejectFriendRequestCommand(long CurrentUserId, Guid FriendshipId) : ICommand<ErrorOr<Success>>;
public class RejectFriendRequestCommandHandler(IDatabase database, IRealtimeNotifier notifier)
: ICommandHandler<RejectFriendRequestCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RejectFriendRequestCommand request, CancellationToken cancellationToken)
{
var friendship = await database.Friendships.FirstOrDefaultAsync(f => f.Id == request.FriendshipId, cancellationToken);
if (friendship is null)
return Error.NotFound(description: $"Friend request {request.FriendshipId} not found");
if (friendship.AddresseeId != request.CurrentUserId)
return Error.Forbidden(description: "Only the addressee can reject this friend request");
if (friendship.Status != FriendshipStatus.Pending)
return Error.Conflict(description: "This friend request is no longer pending");
database.Friendships.Remove(friendship);
await database.SaveChangesAsync(cancellationToken);
await notifier.FriendshipUpdatedAsync(friendship.RequesterId, friendship.Id, cancellationToken);
return new Success();
}
}
}
@@ -0,0 +1,37 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Friends
{
public record RemoveFriendCommand(long CurrentUserId, Guid FriendshipId) : ICommand<ErrorOr<Success>>;
public class RemoveFriendCommandHandler(IDatabase database, IRealtimeNotifier notifier)
: ICommandHandler<RemoveFriendCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RemoveFriendCommand request, CancellationToken cancellationToken)
{
var friendship = await database.Friendships.FirstOrDefaultAsync(f => f.Id == request.FriendshipId, cancellationToken);
if (friendship is null)
return Error.NotFound(description: $"Friendship {request.FriendshipId} not found");
if (friendship.RequesterId != request.CurrentUserId && friendship.AddresseeId != request.CurrentUserId)
return Error.Forbidden(description: "You are not part of this friendship");
if (friendship.Status != FriendshipStatus.Accepted)
return Error.Conflict(description: "This friendship is not active");
var otherUserId = friendship.RequesterId == request.CurrentUserId ? friendship.AddresseeId : friendship.RequesterId;
database.Friendships.Remove(friendship);
await database.SaveChangesAsync(cancellationToken);
await notifier.FriendshipUpdatedAsync(otherUserId, friendship.Id, cancellationToken);
return new Success();
}
}
}
@@ -0,0 +1,25 @@
using Ping.Application.Contracts;
using Ping.Application.Users.Responses;
using Ping.Domain.Entities;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Friends.Responses
{
public record FriendshipResponse(
Guid Id, UserResponse OtherUser, FriendshipStatus Status, bool IsIncoming, DateTime CreatedAt, PresenceStatus Presence)
{
public static FriendshipResponse FromEntity(Friendship friendship, long currentUserId, PresenceStatus presence = PresenceStatus.Offline)
{
var isIncoming = friendship.AddresseeId == currentUserId;
var other = isIncoming ? friendship.Requester : friendship.Addressee;
return new FriendshipResponse(
friendship.Id,
UserResponse.FromEntity(other!),
friendship.Status,
isIncoming,
friendship.CreatedAt,
presence);
}
}
}
@@ -0,0 +1,56 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
using Ping.Application.Friends.Responses;
using Ping.Domain.Entities;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Friends
{
public record SendFriendRequestCommand(long RequesterId, long AddresseeId) : ICommand<ErrorOr<FriendshipResponse>>;
public class SendFriendRequestCommandHandler(IDatabase database, IRealtimeNotifier notifier)
: ICommandHandler<SendFriendRequestCommand, ErrorOr<FriendshipResponse>>
{
public async ValueTask<ErrorOr<FriendshipResponse>> Handle(SendFriendRequestCommand request, CancellationToken cancellationToken)
{
if (request.RequesterId == request.AddresseeId)
return Error.Validation(description: "You cannot send a friend request to yourself");
var addresseeExists = await database.Users.AnyAsync(u => u.Id == request.AddresseeId, cancellationToken);
if (!addresseeExists)
return Error.NotFound(description: $"User {request.AddresseeId} not found");
var existing = await database.Friendships.FirstOrDefaultAsync(f =>
(f.RequesterId == request.RequesterId && f.AddresseeId == request.AddresseeId) ||
(f.RequesterId == request.AddresseeId && f.AddresseeId == request.RequesterId),
cancellationToken);
if (existing is not null)
{
return existing.Status switch
{
FriendshipStatus.Blocked => Error.Forbidden(description: "This friendship is blocked"),
FriendshipStatus.Accepted => Error.Conflict(description: "You are already friends"),
_ => Error.Conflict(description: "A friend request already exists between these users"),
};
}
var friendship = new Friendship
{
Id = Guid.NewGuid(),
RequesterId = request.RequesterId,
AddresseeId = request.AddresseeId,
};
await database.Friendships.AddAsync(friendship, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
await notifier.FriendRequestReceivedAsync(request.AddresseeId, friendship.Id, cancellationToken);
friendship.Requester = await database.Users.FirstAsync(u => u.Id == request.RequesterId, cancellationToken);
friendship.Addressee = await database.Users.FirstAsync(u => u.Id == request.AddresseeId, cancellationToken);
return FriendshipResponse.FromEntity(friendship, request.RequesterId);
}
}
}