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,19 @@
using Mediator;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Ping.Application.Configuration;
namespace Ping.Application
{
public static class ApplicationDependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
{
services.AddMediator(options => options.ServiceLifetime = ServiceLifetime.Scoped);
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
return services;
}
}
}
@@ -0,0 +1,27 @@
using Mediator;
using Ping.Application.Attachments.Responses;
using Ping.Application.Configuration;
using Ping.Application.Contracts;
namespace Ping.Application.Attachments
{
public record RequestAttachmentUploadUrlCommand(long RequesterId, string FileName, string ContentType)
: ICommand<AttachmentUploadResponse>;
public class RequestAttachmentUploadUrlCommandHandler(IStorageService storageService, ApplicationStorageConfiguration storageConfiguration)
: ICommandHandler<RequestAttachmentUploadUrlCommand, AttachmentUploadResponse>
{
private static readonly TimeSpan UploadUrlExpiration = TimeSpan.FromMinutes(10);
public async ValueTask<AttachmentUploadResponse> Handle(RequestAttachmentUploadUrlCommand request, CancellationToken cancellationToken)
{
var key = $"attachments/{request.RequesterId}/{Guid.NewGuid()}/{request.FileName}";
var expiresAt = DateTime.UtcNow + UploadUrlExpiration;
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket, key, request.ContentType, UploadUrlExpiration, cancellationToken);
return new AttachmentUploadResponse(uploadUrl, storageConfiguration.Bucket, key, expiresAt);
}
}
}
@@ -0,0 +1,4 @@
namespace Ping.Application.Attachments.Responses
{
public record AttachmentUploadResponse(string UploadUrl, string Bucket, string Key, DateTime ExpiresAt);
}
@@ -0,0 +1,36 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Calls.Responses;
using Ping.Application.Contracts;
using Ping.Application.Shared;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Calls
{
public record RequestCallTokenCommand(long CurrentUserId, Guid ChannelId) : ICommand<ErrorOr<CallTokenResponse>>;
public class RequestCallTokenCommandHandler(IDatabase database, ICallTokenService callTokenService)
: ICommandHandler<RequestCallTokenCommand, ErrorOr<CallTokenResponse>>
{
public async ValueTask<ErrorOr<CallTokenResponse>> Handle(RequestCallTokenCommand request, CancellationToken cancellationToken)
{
var hasAccess = await database.HasAccessAsync(request.ChannelId, request.CurrentUserId, cancellationToken);
if (!hasAccess)
return Error.NotFound(description: $"Channel {request.ChannelId} not found");
var channel = await database.Channels.AsNoTracking().FirstAsync(c => c.Id == request.ChannelId, cancellationToken);
if (channel.Type != ChannelType.Voice && channel.Type != ChannelType.DirectMessage)
return Error.Validation(description: "Calls are only available in voice channels and direct messages");
var user = await database.Users.AsNoTracking().FirstAsync(u => u.Id == request.CurrentUserId, cancellationToken);
var accessToken = callTokenService.CreateAccessToken(
roomName: request.ChannelId.ToString(),
identity: request.CurrentUserId.ToString(),
displayName: user.DisplayName ?? user.Username);
return new CallTokenResponse(accessToken.Jwt, accessToken.RoomName);
}
}
}
@@ -0,0 +1,4 @@
namespace Ping.Application.Calls.Responses
{
public record CallTokenResponse(string Token, string RoomName);
}
@@ -0,0 +1,49 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Channels.Responses;
using Ping.Application.Contracts;
using Ping.Domain.Entities;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Channels
{
public record CreateChannelCommand(long CurrentUserId, Guid ServerId, string Name, ChannelType Type)
: ICommand<ErrorOr<ChannelResponse>>;
public class CreateChannelCommandHandler(IDatabase database)
: ICommandHandler<CreateChannelCommand, ErrorOr<ChannelResponse>>
{
public async ValueTask<ErrorOr<ChannelResponse>> Handle(CreateChannelCommand request, CancellationToken cancellationToken)
{
var membership = await database.ServerMembers.FirstOrDefaultAsync(
m => m.ServerId == request.ServerId && m.UserId == request.CurrentUserId, cancellationToken);
if (membership is null)
return Error.NotFound(description: $"Server {request.ServerId} not found");
if (membership.Role != ServerMemberRole.Owner)
return Error.Forbidden(description: "Only the server owner can create channels");
if (request.Type == ChannelType.DirectMessage)
return Error.Validation(description: "Server channels cannot be direct messages");
var position = await database.Channels
.Where(c => c.ServerId == request.ServerId)
.CountAsync(cancellationToken);
var channel = new Channel
{
Id = Guid.NewGuid(),
ServerId = request.ServerId,
Name = request.Name,
Type = request.Type,
Position = position,
};
await database.Channels.AddAsync(channel, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
return ChannelResponse.FromEntity(channel);
}
}
}
@@ -0,0 +1,61 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Channels.Responses;
using Ping.Application.Contracts;
using Ping.Domain.Entities;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Channels
{
public record GetOrCreateDirectMessageChannelCommand(long CurrentUserId, long OtherUserId)
: ICommand<ErrorOr<ChannelResponse>>;
public class GetOrCreateDirectMessageChannelCommandHandler(IDatabase database)
: ICommandHandler<GetOrCreateDirectMessageChannelCommand, ErrorOr<ChannelResponse>>
{
public async ValueTask<ErrorOr<ChannelResponse>> Handle(
GetOrCreateDirectMessageChannelCommand request, CancellationToken cancellationToken)
{
if (request.CurrentUserId == request.OtherUserId)
return Error.Validation(description: "You cannot open a direct message channel with yourself");
var areFriends = await database.Friendships.AnyAsync(f =>
f.Status == FriendshipStatus.Accepted &&
((f.RequesterId == request.CurrentUserId && f.AddresseeId == request.OtherUserId) ||
(f.RequesterId == request.OtherUserId && f.AddresseeId == request.CurrentUserId)),
cancellationToken);
if (!areFriends)
return Error.Forbidden(description: "You can only message users on your friends list");
var existingChannelId = await database.ChannelMembers
.Where(m => m.UserId == request.CurrentUserId)
.Select(m => m.ChannelId)
.Intersect(database.ChannelMembers
.Where(m => m.UserId == request.OtherUserId)
.Select(m => m.ChannelId))
.Where(channelId => database.Channels.Any(c => c.Id == channelId && c.Type == ChannelType.DirectMessage))
.FirstOrDefaultAsync(cancellationToken);
if (existingChannelId != Guid.Empty)
{
var existingChannel = await database.Channels.FirstAsync(c => c.Id == existingChannelId, cancellationToken);
return ChannelResponse.FromEntity(existingChannel);
}
var channel = new Channel
{
Id = Guid.NewGuid(),
Type = ChannelType.DirectMessage,
Position = 0,
};
await database.Channels.AddAsync(channel, cancellationToken);
await database.ChannelMembers.AddAsync(new ChannelMember { ChannelId = channel.Id, UserId = request.CurrentUserId }, cancellationToken);
await database.ChannelMembers.AddAsync(new ChannelMember { ChannelId = channel.Id, UserId = request.OtherUserId }, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
return ChannelResponse.FromEntity(channel);
}
}
}
@@ -0,0 +1,30 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Channels.Responses;
using Ping.Application.Contracts;
namespace Ping.Application.Channels
{
public record GetServerChannelsQuery(long CurrentUserId, Guid ServerId) : IQuery<ErrorOr<List<ChannelResponse>>>;
public class GetServerChannelsQueryHandler(IDatabase database)
: IQueryHandler<GetServerChannelsQuery, ErrorOr<List<ChannelResponse>>>
{
public async ValueTask<ErrorOr<List<ChannelResponse>>> Handle(GetServerChannelsQuery request, CancellationToken cancellationToken)
{
var isMember = await database.ServerMembers.AnyAsync(
m => m.ServerId == request.ServerId && m.UserId == request.CurrentUserId, cancellationToken);
if (!isMember)
return Error.NotFound(description: $"Server {request.ServerId} not found");
var channels = await database.Channels.AsNoTracking()
.Where(c => c.ServerId == request.ServerId)
.OrderBy(c => c.Position)
.ToListAsync(cancellationToken);
return channels.Select(ChannelResponse.FromEntity).ToList();
}
}
}
@@ -0,0 +1,11 @@
using Ping.Domain.Entities;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Channels.Responses
{
public record ChannelResponse(Guid Id, Guid? ServerId, string? Name, ChannelType Type, int Position)
{
public static ChannelResponse FromEntity(Channel channel) =>
new(channel.Id, channel.ServerId, channel.Name, channel.Type, channel.Position);
}
}
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Ping.Application.Configuration
{
public class ApplicationStorageConfiguration
{
public const string SectionName = "ApplicationStorage";
[Required]
public required string Bucket { get; set; }
}
}
@@ -0,0 +1,26 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Ping.Application.Configuration
{
public static class ConfigurationDependencyInjection
{
public static IServiceCollection AddValidatedOptions<T>(
this IServiceCollection services,
IConfiguration configuration,
string sectionName)
where T : class
{
services
.AddOptionsWithValidateOnStart<T>()
.Bind(configuration.GetRequiredSection(sectionName))
.ValidateDataAnnotations();
services.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<T>>().Value);
return services;
}
}
}
@@ -0,0 +1,9 @@
namespace Ping.Application.Contracts
{
public record CallAccessToken(string Jwt, string RoomName);
public interface ICallTokenService
{
CallAccessToken CreateAccessToken(string roomName, string identity, string? displayName);
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore;
using Ping.Domain.Entities;
namespace Ping.Application.Contracts
{
public interface IDatabase
{
DbSet<User> Users { get; }
DbSet<Friendship> Friendships { get; }
DbSet<Server> Servers { get; }
DbSet<ServerMember> ServerMembers { get; }
DbSet<Channel> Channels { get; }
DbSet<ChannelMember> ChannelMembers { get; }
DbSet<Message> Messages { get; }
DbSet<Attachment> Attachments { get; }
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
}
@@ -0,0 +1,7 @@
namespace Ping.Application.Contracts
{
public interface IDatabaseTransaction : IAsyncDisposable
{
Task CommitAsync(CancellationToken cancellationToken);
}
}
@@ -0,0 +1,14 @@
namespace Ping.Application.Contracts
{
public enum PresenceStatus
{
Offline,
Online,
Busy
}
public interface IPresenceTracker
{
PresenceStatus GetStatus(long userId);
}
}
@@ -0,0 +1,17 @@
using Ping.Application.Messages.Responses;
namespace Ping.Application.Contracts
{
public interface IRealtimeNotifier
{
Task MessageCreatedAsync(Guid channelId, MessageResponse message, CancellationToken cancellationToken);
Task MessageUpdatedAsync(Guid channelId, MessageResponse message, CancellationToken cancellationToken);
Task MessageDeletedAsync(Guid channelId, Guid messageId, CancellationToken cancellationToken);
Task FriendRequestReceivedAsync(long addresseeId, Guid friendshipId, CancellationToken cancellationToken);
Task FriendshipUpdatedAsync(long userId, Guid friendshipId, CancellationToken cancellationToken);
}
}
@@ -0,0 +1,21 @@
namespace Ping.Application.Contracts
{
public interface IStorageService
{
Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task<string> GetUrlAsync(string bucket, string key, TimeSpan expirationTime, CancellationToken cancellationToken);
Task<string> GetUploadUrlAsync(
string bucket,
string key,
string contentType,
TimeSpan expirationTime,
CancellationToken cancellationToken,
bool preventOverwrite = true);
Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
}
}
@@ -0,0 +1,4 @@
namespace Ping.Application.Contracts
{
public record ObjectMetaData(string? ContentType, long ContentLength);
}
@@ -0,0 +1,4 @@
namespace Ping.Application.Contracts
{
public record StorageObject(string Key, DateTime LastModified);
}
@@ -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);
}
}
}
@@ -0,0 +1,41 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
namespace Ping.Application.Messages
{
public record DeleteMessageCommand(long CurrentUserId, Guid MessageId) : ICommand<ErrorOr<Success>>;
public class DeleteMessageCommandHandler(IDatabase database, IRealtimeNotifier notifier)
: ICommandHandler<DeleteMessageCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(DeleteMessageCommand request, CancellationToken cancellationToken)
{
var message = await database.Messages
.FirstOrDefaultAsync(m => m.Id == request.MessageId && m.DeletedAt == null, cancellationToken);
if (message is null)
return Error.NotFound(description: $"Message {request.MessageId} not found");
if (message.AuthorId != request.CurrentUserId)
{
var isServerOwner = await database.Channels
.Where(c => c.Id == message.ChannelId && c.ServerId != null)
.Select(c => c.ServerId)
.Join(database.Servers, serverId => serverId, server => server.Id, (_, server) => server.OwnerId)
.AnyAsync(ownerId => ownerId == request.CurrentUserId, cancellationToken);
if (!isServerOwner)
return Error.Forbidden(description: "Only the author or the server owner can delete this message");
}
message.DeletedAt = DateTime.UtcNow;
message.Content = string.Empty;
await database.SaveChangesAsync(cancellationToken);
await notifier.MessageDeletedAsync(message.ChannelId, message.Id, cancellationToken);
return new Success();
}
}
}
@@ -0,0 +1,39 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
using Ping.Application.Messages.Responses;
namespace Ping.Application.Messages
{
public record EditMessageCommand(long CurrentUserId, Guid MessageId, string Content) : ICommand<ErrorOr<MessageResponse>>;
public class EditMessageCommandHandler(IDatabase database, IRealtimeNotifier notifier)
: ICommandHandler<EditMessageCommand, ErrorOr<MessageResponse>>
{
public async ValueTask<ErrorOr<MessageResponse>> Handle(EditMessageCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Content))
return Error.Validation(description: "Message content cannot be empty");
var message = await database.Messages
.Include(m => m.Author)
.Include(m => m.Attachments)
.FirstOrDefaultAsync(m => m.Id == request.MessageId && m.DeletedAt == null, cancellationToken);
if (message is null)
return Error.NotFound(description: $"Message {request.MessageId} not found");
if (message.AuthorId != request.CurrentUserId)
return Error.Forbidden(description: "Only the author can edit this message");
message.Content = request.Content;
message.EditedAt = DateTime.UtcNow;
await database.SaveChangesAsync(cancellationToken);
var response = MessageResponse.FromEntity(message);
await notifier.MessageUpdatedAsync(message.ChannelId, response, cancellationToken);
return response;
}
}
}
@@ -0,0 +1,49 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
using Ping.Application.Messages.Responses;
using Ping.Application.Shared;
namespace Ping.Application.Messages
{
public record GetChannelMessagesQuery(long CurrentUserId, Guid ChannelId, Guid? Before, int Take)
: IQuery<ErrorOr<List<MessageResponse>>>;
public class GetChannelMessagesQueryHandler(IDatabase database)
: IQueryHandler<GetChannelMessagesQuery, ErrorOr<List<MessageResponse>>>
{
private const int MaxTake = 100;
public async ValueTask<ErrorOr<List<MessageResponse>>> Handle(GetChannelMessagesQuery request, CancellationToken cancellationToken)
{
var hasAccess = await database.HasAccessAsync(request.ChannelId, request.CurrentUserId, cancellationToken);
if (!hasAccess)
return Error.NotFound(description: $"Channel {request.ChannelId} not found");
var take = Math.Clamp(request.Take, 1, MaxTake);
var query = database.Messages.AsNoTracking()
.Include(m => m.Author)
.Include(m => m.Attachments)
.Where(m => m.ChannelId == request.ChannelId);
if (request.Before is { } beforeId)
{
var cursorCreatedAt = await database.Messages
.Where(m => m.Id == beforeId)
.Select(m => m.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
query = query.Where(m => m.CreatedAt < cursorCreatedAt);
}
var messages = await query
.OrderByDescending(m => m.CreatedAt)
.Take(take)
.ToListAsync(cancellationToken);
return messages.Select(MessageResponse.FromEntity).ToList();
}
}
}
@@ -0,0 +1,11 @@
using Ping.Domain.Entities;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Messages.Responses
{
public record AttachmentResponse(Guid Id, string FileName, string ContentType, long SizeBytes, AttachmentKind Kind)
{
public static AttachmentResponse FromEntity(Attachment attachment) =>
new(attachment.Id, attachment.FileName, attachment.ContentType, attachment.SizeBytes, attachment.Kind);
}
}
@@ -0,0 +1,27 @@
using Ping.Application.Users.Responses;
using Ping.Domain.Entities;
namespace Ping.Application.Messages.Responses
{
public record MessageResponse(
Guid Id,
Guid ChannelId,
UserResponse Author,
string Content,
Guid? ReplyToMessageId,
DateTime CreatedAt,
DateTime? EditedAt,
IReadOnlyList<AttachmentResponse> Attachments)
{
public static MessageResponse FromEntity(Message message) =>
new(
message.Id,
message.ChannelId,
UserResponse.FromEntity(message.Author!),
message.Content,
message.ReplyToMessageId,
message.CreatedAt,
message.EditedAt,
message.Attachments.Select(AttachmentResponse.FromEntity).ToList());
}
}
@@ -0,0 +1,77 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Configuration;
using Ping.Application.Contracts;
using Ping.Application.Messages.Responses;
using Ping.Application.Shared;
using Ping.Domain.Entities;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Messages
{
public record PendingAttachment(string Key, string FileName, string ContentType, AttachmentKind Kind);
public record SendMessageCommand(
long AuthorId,
Guid ChannelId,
string Content,
Guid? ReplyToMessageId,
IReadOnlyList<PendingAttachment> Attachments)
: ICommand<ErrorOr<MessageResponse>>;
public class SendMessageCommandHandler(
IDatabase database,
IStorageService storageService,
ApplicationStorageConfiguration storageConfiguration,
IRealtimeNotifier notifier)
: ICommandHandler<SendMessageCommand, ErrorOr<MessageResponse>>
{
public async ValueTask<ErrorOr<MessageResponse>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Content) && request.Attachments.Count == 0)
return Error.Validation(description: "A message needs content or at least one attachment");
var hasAccess = await database.HasAccessAsync(request.ChannelId, request.AuthorId, cancellationToken);
if (!hasAccess)
return Error.NotFound(description: $"Channel {request.ChannelId} not found");
var message = new Message
{
Id = Guid.NewGuid(),
ChannelId = request.ChannelId,
AuthorId = request.AuthorId,
Content = request.Content,
ReplyToMessageId = request.ReplyToMessageId,
};
foreach (var pending in request.Attachments)
{
var metadata = await storageService.HeadObjectAsync(storageConfiguration.Bucket, pending.Key, cancellationToken);
if (metadata is null)
return Error.Validation(description: $"Attachment '{pending.FileName}' was not found in storage");
message.Attachments.Add(new Attachment
{
Id = Guid.NewGuid(),
MessageId = message.Id,
Bucket = storageConfiguration.Bucket,
Key = pending.Key,
FileName = pending.FileName,
ContentType = pending.ContentType,
SizeBytes = metadata.ContentLength,
Kind = pending.Kind,
});
}
await database.Messages.AddAsync(message, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
message.Author = await database.Users.FirstAsync(u => u.Id == request.AuthorId, cancellationToken);
var response = MessageResponse.FromEntity(message);
await notifier.MessageCreatedAsync(request.ChannelId, response, cancellationToken);
return response;
}
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Ping.Application</AssemblyName>
<RootNamespace>Ping.Application</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ErrorOr" />
<PackageReference Include="Mediator.Abstractions" />
<PackageReference Include="Mediator.SourceGenerator">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Ping.Domain\Ping.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,38 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Ping.Application.Serialization
{
public sealed class LongAsStringConverter : JsonConverter<long>
{
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
reader.TokenType == JsonTokenType.String
? long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
: reader.GetInt64();
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
}
public sealed class NullableLongAsStringConverter : JsonConverter<long?>
{
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
return reader.TokenType == JsonTokenType.String
? string.IsNullOrEmpty(reader.GetString()) ? null : long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
: reader.GetInt64();
}
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
{
if (value.HasValue)
writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture));
else
writer.WriteNullValue();
}
}
}
@@ -0,0 +1,44 @@
using Mediator;
using Ping.Application.Contracts;
using Ping.Application.Servers.Responses;
using Ping.Domain.Entities;
using Ping.Domain.ValueObjects;
namespace Ping.Application.Servers
{
public record CreateServerCommand(long OwnerId, string Name) : ICommand<ServerResponse>;
public class CreateServerCommandHandler(IDatabase database)
: ICommandHandler<CreateServerCommand, ServerResponse>
{
public async ValueTask<ServerResponse> Handle(CreateServerCommand request, CancellationToken cancellationToken)
{
var server = new Server
{
Id = Guid.NewGuid(),
Name = request.Name,
OwnerId = request.OwnerId,
};
await database.Servers.AddAsync(server, cancellationToken);
await database.ServerMembers.AddAsync(new ServerMember
{
ServerId = server.Id,
UserId = request.OwnerId,
Role = ServerMemberRole.Owner,
}, cancellationToken);
await database.Channels.AddAsync(new Channel
{
Id = Guid.NewGuid(),
ServerId = server.Id,
Name = "general",
Type = ChannelType.Text,
Position = 0,
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
return ServerResponse.FromEntity(server);
}
}
}
@@ -0,0 +1,23 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
using Ping.Application.Servers.Responses;
namespace Ping.Application.Servers
{
public record GetUserServersQuery(long UserId) : IQuery<IReadOnlyList<ServerResponse>>;
public class GetUserServersQueryHandler(IDatabase database)
: IQueryHandler<GetUserServersQuery, IReadOnlyList<ServerResponse>>
{
public async ValueTask<IReadOnlyList<ServerResponse>> Handle(GetUserServersQuery request, CancellationToken cancellationToken)
{
var servers = await database.ServerMembers.AsNoTracking()
.Where(m => m.UserId == request.UserId)
.Select(m => m.Server!)
.ToListAsync(cancellationToken);
return servers.Select(ServerResponse.FromEntity).ToList();
}
}
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
using Ping.Application.Serialization;
using Ping.Domain.Entities;
namespace Ping.Application.Servers.Responses
{
public record ServerResponse(Guid Id, string Name, string? IconUrl, [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerId)
{
public static ServerResponse FromEntity(Server server) =>
new(server.Id, server.Name, server.IconUrl, server.OwnerId);
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
namespace Ping.Application.Shared
{
public static class ChannelAccess
{
public static async Task<bool> HasAccessAsync(this IDatabase database, Guid channelId, long userId, CancellationToken cancellationToken)
{
var isDirectMessageParticipant = await database.ChannelMembers
.AnyAsync(m => m.ChannelId == channelId && m.UserId == userId, cancellationToken);
if (isDirectMessageParticipant)
return true;
return await database.Channels.AnyAsync(c =>
c.Id == channelId && c.ServerId != null &&
database.ServerMembers.Any(sm => sm.ServerId == c.ServerId && sm.UserId == userId),
cancellationToken);
}
}
}
@@ -0,0 +1,22 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
using Ping.Application.Users.Responses;
namespace Ping.Application.Users
{
public record GetUserByIdQuery(long Id) : IQuery<ErrorOr<UserResponse>>;
public class GetUserByIdQueryHandler(IDatabase database)
: IQueryHandler<GetUserByIdQuery, ErrorOr<UserResponse>>
{
public async ValueTask<ErrorOr<UserResponse>> Handle(GetUserByIdQuery request, CancellationToken cancellationToken)
{
var user = await database.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == request.Id, cancellationToken);
return user is null
? Error.NotFound(description: $"User {request.Id} not found")
: UserResponse.FromEntity(user);
}
}
}
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
using Ping.Application.Serialization;
using Ping.Domain.Entities;
namespace Ping.Application.Users.Responses
{
public record UserResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
string Username,
string? DisplayName,
string? AvatarUrl)
{
public static UserResponse FromEntity(User user) =>
new(user.Id, user.Username, user.DisplayName, user.AvatarUrl);
}
}
@@ -0,0 +1,28 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Ping.Application.Contracts;
using Ping.Application.Users.Responses;
namespace Ping.Application.Users
{
public record SearchUsersQuery(string UsernameQuery, long ExcludingUserId) : IQuery<IReadOnlyList<UserResponse>>;
public class SearchUsersQueryHandler(IDatabase database)
: IQueryHandler<SearchUsersQuery, IReadOnlyList<UserResponse>>
{
public async ValueTask<IReadOnlyList<UserResponse>> Handle(SearchUsersQuery request, CancellationToken cancellationToken)
{
var normalizedQuery = request.UsernameQuery.Trim().ToUpperInvariant();
if (normalizedQuery.Length == 0)
return [];
var users = await database.Users.AsNoTracking()
.Where(u => u.Id != request.ExcludingUserId && u.NormalizedUsername.Contains(normalizedQuery))
.OrderBy(u => u.Username)
.Take(20)
.ToListAsync(cancellationToken);
return users.Select(UserResponse.FromEntity).ToList();
}
}
}
@@ -0,0 +1,45 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Ping.Application.Contracts;
using Ping.Application.Users.Responses;
using Ping.Domain.Entities;
namespace Ping.Application.Users
{
public record SyncUserCommand(long Id, string Username, string? DisplayName, string? AvatarUrl)
: ICommand<UserResponse>;
public class SyncUserCommandHandler(IDatabase database, ILogger<SyncUserCommandHandler> logger)
: ICommandHandler<SyncUserCommand, UserResponse>
{
public async ValueTask<UserResponse> Handle(SyncUserCommand request, CancellationToken cancellationToken)
{
var user = await database.Users.FirstOrDefaultAsync(u => u.Id == request.Id, cancellationToken);
if (user is null)
{
user = new User
{
Id = request.Id,
Username = request.Username,
NormalizedUsername = request.Username.ToUpperInvariant(),
DisplayName = request.DisplayName,
AvatarUrl = request.AvatarUrl,
};
await database.Users.AddAsync(user, cancellationToken);
logger.LogInformation("Provisioned user {UserId} from identity provider", user.Id);
}
else
{
user.Username = request.Username;
user.NormalizedUsername = request.Username.ToUpperInvariant();
user.DisplayName = request.DisplayName;
user.AvatarUrl = request.AvatarUrl;
}
await database.SaveChangesAsync(cancellationToken);
return UserResponse.FromEntity(user);
}
}
}
@@ -0,0 +1,9 @@
namespace Ping.Domain.Abstractions
{
public interface IAuditable
{
DateTime CreatedAt { get; set; }
DateTime UpdatedAt { get; set; }
}
}
@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Ping.Domain.ValueObjects;
namespace Ping.Domain.Entities
{
[Table("Attachment")]
public class Attachment
{
[Key]
public Guid Id { get; set; }
public required Guid MessageId { get; set; }
public Message? Message { get; set; }
[Required]
[MaxLength(255)]
public required string Bucket { get; set; }
[Required]
[MaxLength(1024)]
public required string Key { get; set; }
[Required]
[MaxLength(255)]
public required string FileName { get; set; }
[Required]
[MaxLength(255)]
public required string ContentType { get; set; }
public required long SizeBytes { get; set; }
[Required]
public AttachmentKind Kind { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
}
@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Ping.Domain.ValueObjects;
namespace Ping.Domain.Entities
{
[Table("Channel")]
public class Channel
{
[Key]
public Guid Id { get; set; }
public Guid? ServerId { get; set; }
public Server? ServerNavigation { get; set; }
[MaxLength(100)]
public string? Name { get; set; }
[MaxLength(1024)]
public string? Topic { get; set; }
[Required]
public ChannelType Type { get; set; }
[Required]
public int Position { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<ChannelMember> Members { get; set; } = new List<ChannelMember>();
}
}
@@ -0,0 +1,13 @@
namespace Ping.Domain.Entities
{
public class ChannelMember
{
public required Guid ChannelId { get; set; }
public Channel? Channel { get; set; }
public required long UserId { get; set; }
public User? User { get; set; }
}
}
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Ping.Domain.ValueObjects;
namespace Ping.Domain.Entities
{
[Table("Friendship")]
public class Friendship
{
[Key]
public Guid Id { get; set; }
public required long RequesterId { get; set; }
public User? Requester { get; set; }
public required long AddresseeId { get; set; }
public User? Addressee { get; set; }
[Required]
public FriendshipStatus Status { get; set; } = FriendshipStatus.Pending;
public long? BlockedById { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? RespondedAt { get; set; }
}
}
@@ -0,0 +1,35 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Ping.Domain.Entities
{
[Table("Message")]
public class Message
{
[Key]
public Guid Id { get; set; }
public required Guid ChannelId { get; set; }
public Channel? Channel { get; set; }
public required long AuthorId { get; set; }
public User? Author { get; set; }
[Required]
[MaxLength(4000)]
public required string Content { get; set; }
public Guid? ReplyToMessageId { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? EditedAt { get; set; }
public DateTime? DeletedAt { get; set; }
public ICollection<Attachment> Attachments { get; set; } = new List<Attachment>();
}
}
@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Ping.Domain.Abstractions;
namespace Ping.Domain.Entities
{
[Table("Server")]
public class Server : IAuditable
{
[Key]
public Guid Id { get; set; }
[Required]
[MaxLength(100)]
public required string Name { get; set; }
[MaxLength(2048)]
public string? IconUrl { get; set; }
public required long OwnerId { get; set; }
public User? Owner { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Required]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<ServerMember> Members { get; set; } = new List<ServerMember>();
public ICollection<Channel> Channels { get; set; } = new List<Channel>();
}
}
@@ -0,0 +1,27 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Ping.Domain.ValueObjects;
namespace Ping.Domain.Entities
{
[Table("ServerMember")]
public class ServerMember
{
public required Guid ServerId { get; set; }
public Server? Server { get; set; }
public required long UserId { get; set; }
public User? User { get; set; }
[MaxLength(64)]
public string? Nickname { get; set; }
[Required]
public ServerMemberRole Role { get; set; } = ServerMemberRole.Member;
[Required]
public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
}
}
+36
View File
@@ -0,0 +1,36 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Ping.Domain.Abstractions;
namespace Ping.Domain.Entities
{
[Table("User")]
public class User : IAuditable
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.None)]
public required long Id { get; set; }
[Required]
[MaxLength(64)]
public required string Username { get; set; }
[Required]
[MaxLength(64)]
public required string NormalizedUsername { get; set; }
[MaxLength(64)]
public string? DisplayName { get; set; }
[MaxLength(2048)]
public string? AvatarUrl { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Required]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<ServerMember> ServerMemberships { get; set; } = new List<ServerMember>();
}
}
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Ping.Domain</AssemblyName>
<RootNamespace>Ping.Domain</RootNamespace>
</PropertyGroup>
</Project>
@@ -0,0 +1,9 @@
namespace Ping.Domain.ValueObjects
{
public enum AttachmentKind
{
File = 0,
Image = 1,
Audio = 2,
}
}
@@ -0,0 +1,9 @@
namespace Ping.Domain.ValueObjects
{
public enum ChannelType
{
Text = 0,
Voice = 1,
DirectMessage = 2,
}
}
@@ -0,0 +1,9 @@
namespace Ping.Domain.ValueObjects
{
public enum FriendshipStatus
{
Pending = 0,
Accepted = 1,
Blocked = 2,
}
}
@@ -0,0 +1,8 @@
namespace Ping.Domain.ValueObjects
{
public enum ServerMemberRole
{
Member = 0,
Owner = 1,
}
}
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Ping.Infrastructure.Configuration
{
public class DatabaseConfiguration
{
public const string SectionName = "Database";
[Required]
public required string ConnectionString { get; set; }
}
}
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace Ping.Infrastructure.Configuration
{
public class InfrastructureStorageConfiguration
{
public const string SectionName = "InfrastructureStorage";
[Url]
[Required]
public required string Address { get; set; }
[Required]
public required string AccessKey { get; set; }
[Required]
public required string SecretAccessKey { get; set; }
[Required]
public bool ForcePathStyle { get; set; }
[Required]
public bool UseHttp { get; set; }
}
}
@@ -0,0 +1,17 @@
using System.ComponentModel.DataAnnotations;
namespace Ping.Infrastructure.Configuration
{
public class LiveKitConfiguration
{
public const string SectionName = "LiveKit";
[Required]
public required string ApiKey { get; set; }
[Required]
public required string ApiSecret { get; set; }
public int TokenTtlMinutes { get; set; } = 240;
}
}
@@ -0,0 +1,5 @@
{
"Database": {
"ConnectionString": ""
}
}
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Ping.Domain.Abstractions;
namespace Ping.Infrastructure.Persistence
{
public sealed class AuditableEntityInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
ApplyTimestamps(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
ApplyTimestamps(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void ApplyTimestamps(DbContext? context)
{
if (context is null)
return;
context.ChangeTracker.DetectChanges();
var now = DateTime.UtcNow;
foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
entry.Entity.UpdatedAt = now;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
}
}
}
}
}
@@ -0,0 +1,131 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Ping.Domain.Entities;
using Ping.Infrastructure.Configuration;
using AppIDatabase = Ping.Application.Contracts.IDatabase;
namespace Ping.Infrastructure.Persistence
{
public class Database(DatabaseConfiguration configuration) : DbContext, AppIDatabase
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseNpgsql(configuration.ConnectionString);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<ServerMember>()
.HasKey(m => new { m.ServerId, m.UserId });
modelBuilder.Entity<ServerMember>()
.HasOne(m => m.Server)
.WithMany(s => s.Members)
.HasForeignKey(m => m.ServerId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ServerMember>()
.HasOne(m => m.User)
.WithMany(u => u.ServerMemberships)
.HasForeignKey(m => m.UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Server>()
.HasOne(s => s.Owner)
.WithMany()
.HasForeignKey(s => s.OwnerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Channel>()
.HasOne(c => c.ServerNavigation)
.WithMany(s => s.Channels)
.HasForeignKey(c => c.ServerId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ChannelMember>()
.HasKey(m => new { m.ChannelId, m.UserId });
modelBuilder.Entity<ChannelMember>()
.HasOne(m => m.Channel)
.WithMany(c => c.Members)
.HasForeignKey(m => m.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<ChannelMember>()
.HasOne(m => m.User)
.WithMany()
.HasForeignKey(m => m.UserId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Message>()
.HasOne(m => m.Channel)
.WithMany()
.HasForeignKey(m => m.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Message>()
.HasOne(m => m.Author)
.WithMany()
.HasForeignKey(m => m.AuthorId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Message>()
.HasIndex(m => new { m.ChannelId, m.CreatedAt });
modelBuilder.Entity<Attachment>()
.HasOne(a => a.Message)
.WithMany(m => m.Attachments)
.HasForeignKey(a => a.MessageId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Friendship>()
.HasOne(f => f.Requester)
.WithMany()
.HasForeignKey(f => f.RequesterId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Friendship>()
.HasOne(f => f.Addressee)
.WithMany()
.HasForeignKey(f => f.AddresseeId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Friendship>()
.HasIndex(f => new { f.RequesterId, f.AddresseeId })
.IsUnique();
}
public DbSet<User> Users => Set<User>();
public DbSet<Friendship> Friendships => Set<Friendship>();
public DbSet<Server> Servers => Set<Server>();
public DbSet<ServerMember> ServerMembers => Set<ServerMember>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<ChannelMember> ChannelMembers => Set<ChannelMember>();
public DbSet<Message> Messages => Set<Message>();
public DbSet<Attachment> Attachments => Set<Attachment>();
public async Task<Ping.Application.Contracts.IDatabaseTransaction> BeginTransactionAsync(
System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
{
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
return new DatabaseTransaction(transaction);
}
private sealed class DatabaseTransaction(IDbContextTransaction inner) : Ping.Application.Contracts.IDatabaseTransaction
{
public Task CommitAsync(CancellationToken cancellationToken) =>
inner.CommitAsync(cancellationToken);
public ValueTask DisposeAsync() => inner.DisposeAsync();
}
}
}
@@ -0,0 +1,31 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Ping.Application.Contracts;
using Ping.Infrastructure.Configuration;
namespace Ping.Infrastructure.Persistence
{
public static class DatabaseDependencyInjection
{
public static IServiceCollection AddDatabase(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddOptionsWithValidateOnStart<DatabaseConfiguration>()
.Bind(configuration.GetRequiredSection(DatabaseConfiguration.SectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value);
serviceDescriptors.AddSingleton<AuditableEntityInterceptor>();
serviceDescriptors.AddDbContext<Database>((serviceProvider, options) =>
options.AddInterceptors(serviceProvider.GetRequiredService<AuditableEntityInterceptor>()));
serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>());
return serviceDescriptors;
}
}
}
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Configuration;
using Ping.Infrastructure.Configuration;
namespace Ping.Infrastructure.Persistence
{
public class DatabaseDesignTimeFactory : IDesignTimeDbContextFactory<Database>
{
public Database CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("DesignSettings.json", optional: true)
.AddUserSecrets<DatabaseDesignTimeFactory>()
.AddEnvironmentVariables()
.Build();
var databaseConfiguration = configuration
.GetRequiredSection(DatabaseConfiguration.SectionName)
.Get<DatabaseConfiguration>()
?? throw new InvalidOperationException($"{DatabaseConfiguration.SectionName} configuration section not found.");
return new Database(databaseConfiguration);
}
}
}
@@ -0,0 +1,413 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Ping.Infrastructure.Persistence;
#nullable disable
namespace Ping.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(Database))]
[Migration("20260916023908_InitialCreate")]
partial class InitialCreate
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Ping.Domain.Entities.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("MessageId");
b.ToTable("Attachment");
});
modelBuilder.Entity("Ping.Domain.Entities.Channel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid?>("ServerId")
.HasColumnType("uuid");
b.Property<string>("Topic")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ServerId");
b.ToTable("Channel");
});
modelBuilder.Entity("Ping.Domain.Entities.ChannelMember", b =>
{
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("ChannelId", "UserId");
b.HasIndex("UserId");
b.ToTable("ChannelMembers");
});
modelBuilder.Entity("Ping.Domain.Entities.Friendship", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<long>("AddresseeId")
.HasColumnType("bigint");
b.Property<long?>("BlockedById")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("RequesterId")
.HasColumnType("bigint");
b.Property<DateTime?>("RespondedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("AddresseeId");
b.HasIndex("RequesterId", "AddresseeId")
.IsUnique();
b.ToTable("Friendship");
});
modelBuilder.Entity("Ping.Domain.Entities.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<long>("AuthorId")
.HasColumnType("bigint");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("EditedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ReplyToMessageId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("ChannelId", "CreatedAt");
b.ToTable("Message");
});
modelBuilder.Entity("Ping.Domain.Entities.Server", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("IconUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("OwnerId")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OwnerId");
b.ToTable("Server");
});
modelBuilder.Entity("Ping.Domain.Entities.ServerMember", b =>
{
b.Property<Guid>("ServerId")
.HasColumnType("uuid");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Nickname")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Role")
.HasColumnType("integer");
b.HasKey("ServerId", "UserId");
b.HasIndex("UserId");
b.ToTable("ServerMember");
});
modelBuilder.Entity("Ping.Domain.Entities.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint");
b.Property<string>("AvatarUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NormalizedUsername")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.ToTable("User");
});
modelBuilder.Entity("Ping.Domain.Entities.Attachment", b =>
{
b.HasOne("Ping.Domain.Entities.Message", "Message")
.WithMany("Attachments")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Message");
});
modelBuilder.Entity("Ping.Domain.Entities.Channel", b =>
{
b.HasOne("Ping.Domain.Entities.Server", "ServerNavigation")
.WithMany("Channels")
.HasForeignKey("ServerId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("ServerNavigation");
});
modelBuilder.Entity("Ping.Domain.Entities.ChannelMember", b =>
{
b.HasOne("Ping.Domain.Entities.Channel", "Channel")
.WithMany("Members")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Ping.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Channel");
b.Navigation("User");
});
modelBuilder.Entity("Ping.Domain.Entities.Friendship", b =>
{
b.HasOne("Ping.Domain.Entities.User", "Addressee")
.WithMany()
.HasForeignKey("AddresseeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Ping.Domain.Entities.User", "Requester")
.WithMany()
.HasForeignKey("RequesterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Addressee");
b.Navigation("Requester");
});
modelBuilder.Entity("Ping.Domain.Entities.Message", b =>
{
b.HasOne("Ping.Domain.Entities.User", "Author")
.WithMany()
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Ping.Domain.Entities.Channel", "Channel")
.WithMany()
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
b.Navigation("Channel");
});
modelBuilder.Entity("Ping.Domain.Entities.Server", b =>
{
b.HasOne("Ping.Domain.Entities.User", "Owner")
.WithMany()
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("Ping.Domain.Entities.ServerMember", b =>
{
b.HasOne("Ping.Domain.Entities.Server", "Server")
.WithMany("Members")
.HasForeignKey("ServerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Ping.Domain.Entities.User", "User")
.WithMany("ServerMemberships")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Server");
b.Navigation("User");
});
modelBuilder.Entity("Ping.Domain.Entities.Channel", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("Ping.Domain.Entities.Message", b =>
{
b.Navigation("Attachments");
});
modelBuilder.Entity("Ping.Domain.Entities.Server", b =>
{
b.Navigation("Channels");
b.Navigation("Members");
});
modelBuilder.Entity("Ping.Domain.Entities.User", b =>
{
b.Navigation("ServerMemberships");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,283 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Ping.Infrastructure.Persistence.Migrations
{
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "User",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false),
Username = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
NormalizedUsername = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
DisplayName = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
AvatarUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_User", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Friendship",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
RequesterId = table.Column<long>(type: "bigint", nullable: false),
AddresseeId = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
BlockedById = table.Column<long>(type: "bigint", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
RespondedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Friendship", x => x.Id);
table.ForeignKey(
name: "FK_Friendship_User_AddresseeId",
column: x => x.AddresseeId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Friendship_User_RequesterId",
column: x => x.RequesterId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Server",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
IconUrl = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true),
OwnerId = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Server", x => x.Id);
table.ForeignKey(
name: "FK_Server_User_OwnerId",
column: x => x.OwnerId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Channel",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ServerId = table.Column<Guid>(type: "uuid", nullable: true),
Name = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
Topic = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
Type = table.Column<int>(type: "integer", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Channel", x => x.Id);
table.ForeignKey(
name: "FK_Channel_Server_ServerId",
column: x => x.ServerId,
principalTable: "Server",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ServerMember",
columns: table => new
{
ServerId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false),
Nickname = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
Role = table.Column<int>(type: "integer", nullable: false),
JoinedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ServerMember", x => new { x.ServerId, x.UserId });
table.ForeignKey(
name: "FK_ServerMember_Server_ServerId",
column: x => x.ServerId,
principalTable: "Server",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ServerMember_User_UserId",
column: x => x.UserId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ChannelMembers",
columns: table => new
{
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelMembers", x => new { x.ChannelId, x.UserId });
table.ForeignKey(
name: "FK_ChannelMembers_Channel_ChannelId",
column: x => x.ChannelId,
principalTable: "Channel",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ChannelMembers_User_UserId",
column: x => x.UserId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Message",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
AuthorId = table.Column<long>(type: "bigint", nullable: false),
Content = table.Column<string>(type: "character varying(4000)", maxLength: 4000, nullable: false),
ReplyToMessageId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
EditedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Message", x => x.Id);
table.ForeignKey(
name: "FK_Message_Channel_ChannelId",
column: x => x.ChannelId,
principalTable: "Channel",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Message_User_AuthorId",
column: x => x.AuthorId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Attachment",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
MessageId = table.Column<Guid>(type: "uuid", nullable: false),
Bucket = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
Key = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
FileName = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
ContentType = table.Column<string>(type: "character varying(255)", maxLength: 255, nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Attachment", x => x.Id);
table.ForeignKey(
name: "FK_Attachment_Message_MessageId",
column: x => x.MessageId,
principalTable: "Message",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Attachment_MessageId",
table: "Attachment",
column: "MessageId");
migrationBuilder.CreateIndex(
name: "IX_Channel_ServerId",
table: "Channel",
column: "ServerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelMembers_UserId",
table: "ChannelMembers",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Friendship_AddresseeId",
table: "Friendship",
column: "AddresseeId");
migrationBuilder.CreateIndex(
name: "IX_Friendship_RequesterId_AddresseeId",
table: "Friendship",
columns: new[] { "RequesterId", "AddresseeId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Message_AuthorId",
table: "Message",
column: "AuthorId");
migrationBuilder.CreateIndex(
name: "IX_Message_ChannelId_CreatedAt",
table: "Message",
columns: new[] { "ChannelId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_Server_OwnerId",
table: "Server",
column: "OwnerId");
migrationBuilder.CreateIndex(
name: "IX_ServerMember_UserId",
table: "ServerMember",
column: "UserId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Attachment");
migrationBuilder.DropTable(
name: "ChannelMembers");
migrationBuilder.DropTable(
name: "Friendship");
migrationBuilder.DropTable(
name: "ServerMember");
migrationBuilder.DropTable(
name: "Message");
migrationBuilder.DropTable(
name: "Channel");
migrationBuilder.DropTable(
name: "Server");
migrationBuilder.DropTable(
name: "User");
}
}
}
@@ -0,0 +1,411 @@
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Ping.Infrastructure.Persistence;
#nullable disable
namespace Ping.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(Database))]
partial class DatabaseModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Ping.Domain.Entities.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Bucket")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("MessageId");
b.ToTable("Attachment");
});
modelBuilder.Entity("Ping.Domain.Entities.Channel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<Guid?>("ServerId")
.HasColumnType("uuid");
b.Property<string>("Topic")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<int>("Type")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ServerId");
b.ToTable("Channel");
});
modelBuilder.Entity("Ping.Domain.Entities.ChannelMember", b =>
{
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("ChannelId", "UserId");
b.HasIndex("UserId");
b.ToTable("ChannelMembers");
});
modelBuilder.Entity("Ping.Domain.Entities.Friendship", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<long>("AddresseeId")
.HasColumnType("bigint");
b.Property<long?>("BlockedById")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("RequesterId")
.HasColumnType("bigint");
b.Property<DateTime?>("RespondedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("AddresseeId");
b.HasIndex("RequesterId", "AddresseeId")
.IsUnique();
b.ToTable("Friendship");
});
modelBuilder.Entity("Ping.Domain.Entities.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<long>("AuthorId")
.HasColumnType("bigint");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("EditedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ReplyToMessageId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("ChannelId", "CreatedAt");
b.ToTable("Message");
});
modelBuilder.Entity("Ping.Domain.Entities.Server", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("IconUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<long>("OwnerId")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("OwnerId");
b.ToTable("Server");
});
modelBuilder.Entity("Ping.Domain.Entities.ServerMember", b =>
{
b.Property<Guid>("ServerId")
.HasColumnType("uuid");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Nickname")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Role")
.HasColumnType("integer");
b.HasKey("ServerId", "UserId");
b.HasIndex("UserId");
b.ToTable("ServerMember");
});
modelBuilder.Entity("Ping.Domain.Entities.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint");
b.Property<string>("AvatarUrl")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NormalizedUsername")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.ToTable("User");
});
modelBuilder.Entity("Ping.Domain.Entities.Attachment", b =>
{
b.HasOne("Ping.Domain.Entities.Message", "Message")
.WithMany("Attachments")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Message");
});
modelBuilder.Entity("Ping.Domain.Entities.Channel", b =>
{
b.HasOne("Ping.Domain.Entities.Server", "ServerNavigation")
.WithMany("Channels")
.HasForeignKey("ServerId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("ServerNavigation");
});
modelBuilder.Entity("Ping.Domain.Entities.ChannelMember", b =>
{
b.HasOne("Ping.Domain.Entities.Channel", "Channel")
.WithMany("Members")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Ping.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Channel");
b.Navigation("User");
});
modelBuilder.Entity("Ping.Domain.Entities.Friendship", b =>
{
b.HasOne("Ping.Domain.Entities.User", "Addressee")
.WithMany()
.HasForeignKey("AddresseeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Ping.Domain.Entities.User", "Requester")
.WithMany()
.HasForeignKey("RequesterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Addressee");
b.Navigation("Requester");
});
modelBuilder.Entity("Ping.Domain.Entities.Message", b =>
{
b.HasOne("Ping.Domain.Entities.User", "Author")
.WithMany()
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Ping.Domain.Entities.Channel", "Channel")
.WithMany()
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
b.Navigation("Channel");
});
modelBuilder.Entity("Ping.Domain.Entities.Server", b =>
{
b.HasOne("Ping.Domain.Entities.User", "Owner")
.WithMany()
.HasForeignKey("OwnerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Owner");
});
modelBuilder.Entity("Ping.Domain.Entities.ServerMember", b =>
{
b.HasOne("Ping.Domain.Entities.Server", "Server")
.WithMany("Members")
.HasForeignKey("ServerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Ping.Domain.Entities.User", "User")
.WithMany("ServerMemberships")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Server");
b.Navigation("User");
});
modelBuilder.Entity("Ping.Domain.Entities.Channel", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("Ping.Domain.Entities.Message", b =>
{
b.Navigation("Attachments");
});
modelBuilder.Entity("Ping.Domain.Entities.Server", b =>
{
b.Navigation("Channels");
b.Navigation("Members");
});
modelBuilder.Entity("Ping.Domain.Entities.User", b =>
{
b.Navigation("ServerMemberships");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>Ping.Infrastructure</AssemblyName>
<RootNamespace>Ping.Infrastructure</RootNamespace>
<UserSecretsId>8baf35f2-9845-462f-a87a-570d56d13c58</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Livekit.Server.Sdk.Dotnet" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Ping.Application\Ping.Application.csproj" />
<ProjectReference Include="..\Ping.Domain\Ping.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Persistence\Migrations\" />
</ItemGroup>
</Project>
@@ -0,0 +1,27 @@
using Livekit.Server.Sdk.Dotnet;
using Ping.Application.Contracts;
using Ping.Infrastructure.Configuration;
namespace Ping.Infrastructure.Services
{
public class CallTokenService(LiveKitConfiguration configuration) : ICallTokenService
{
public CallAccessToken CreateAccessToken(string roomName, string identity, string? displayName)
{
var token = new AccessToken(configuration.ApiKey, configuration.ApiSecret)
.WithIdentity(identity)
.WithName(displayName ?? identity)
.WithGrants(new VideoGrants
{
RoomJoin = true,
Room = roomName,
CanPublish = true,
CanSubscribe = true,
CanPublishData = true,
})
.WithTtl(TimeSpan.FromMinutes(configuration.TokenTtlMinutes));
return new CallAccessToken(token.ToJwt(), roomName);
}
}
}
@@ -0,0 +1,52 @@
using Amazon.S3;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Ping.Application.Contracts;
using Ping.Infrastructure.Configuration;
namespace Ping.Infrastructure.Services
{
public static class ServicesDependencyInjection
{
public static IServiceCollection AddStorageService(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddOptionsWithValidateOnStart<InfrastructureStorageConfiguration>()
.Bind(configuration.GetRequiredSection(InfrastructureStorageConfiguration.SectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<InfrastructureStorageConfiguration>>().Value);
serviceDescriptors.AddSingleton<IAmazonS3>(serviceProvider =>
{
var storageConfiguration = serviceProvider.GetRequiredService<InfrastructureStorageConfiguration>();
var s3Configuration = new AmazonS3Config
{
ServiceURL = storageConfiguration.Address,
ForcePathStyle = storageConfiguration.ForcePathStyle,
UseHttp = storageConfiguration.UseHttp,
};
return new AmazonS3Client(storageConfiguration.AccessKey, storageConfiguration.SecretAccessKey, s3Configuration);
});
serviceDescriptors.AddScoped<IStorageService, StorageService>();
return serviceDescriptors;
}
public static IServiceCollection AddCallTokenService(this IServiceCollection serviceDescriptors, IConfiguration configuration)
{
serviceDescriptors
.AddOptionsWithValidateOnStart<LiveKitConfiguration>()
.Bind(configuration.GetRequiredSection(LiveKitConfiguration.SectionName))
.ValidateDataAnnotations();
serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<LiveKitConfiguration>>().Value);
serviceDescriptors.AddSingleton<ICallTokenService, CallTokenService>();
return serviceDescriptors;
}
}
}
@@ -0,0 +1,94 @@
using Amazon.S3;
using Amazon.S3.Model;
using Microsoft.Extensions.Logging;
using Ping.Application.Contracts;
using Ping.Infrastructure.Configuration;
namespace Ping.Infrastructure.Services
{
public class StorageService(IAmazonS3 amazonS3, ILogger<StorageService> logger, InfrastructureStorageConfiguration storageClientConfiguration)
: IStorageService
{
public async Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken)
{
var request = new GetObjectRequest { BucketName = bucket, Key = key };
try
{
var response = await amazonS3.GetObjectAsync(request, cancellationToken);
logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key);
return response.ResponseStream;
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key);
throw;
}
}
public async Task<string> GetUrlAsync(string bucket, string key, TimeSpan expirationTime, CancellationToken cancellationToken)
{
var request = new GetPreSignedUrlRequest
{
BucketName = bucket,
Key = key,
Expires = DateTime.UtcNow + expirationTime,
Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS,
};
logger.LogDebug("Generating pre-signed URL for {Bucket}/{Key}", bucket, key);
return await amazonS3.GetPreSignedURLAsync(request);
}
public async Task<string> GetUploadUrlAsync(
string bucket,
string key,
string contentType,
TimeSpan expirationTime,
CancellationToken cancellationToken,
bool preventOverwrite = true)
{
var request = new GetPreSignedUrlRequest
{
BucketName = bucket,
Key = key,
Expires = DateTime.UtcNow + expirationTime,
Protocol = storageClientConfiguration.UseHttp ? Protocol.HTTP : Protocol.HTTPS,
Verb = HttpVerb.PUT,
ContentType = contentType,
};
if (preventOverwrite)
request.Headers["If-None-Match"] = "*";
logger.LogDebug("Generating pre-signed upload URL for {Bucket}/{Key}", bucket, key);
return await amazonS3.GetPreSignedURLAsync(request);
}
public async Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken)
{
try
{
var response = await amazonS3.GetObjectMetadataAsync(bucket, key, cancellationToken);
return new ObjectMetaData(response.Headers.ContentType, response.ContentLength);
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
catch (Exception exception)
{
logger.LogError(exception, "HEAD failed for {Bucket}/{Key}", bucket, key);
throw;
}
}
public async Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken)
{
var request = new DeleteObjectRequest { BucketName = bucket, Key = key };
logger.LogDebug("Removing file from S3 {Bucket}/{Key}", bucket, key);
await amazonS3.DeleteObjectAsync(request, cancellationToken);
}
}
}