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:
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "ping-api",
|
||||||
|
"runtimeExecutable": "dotnet",
|
||||||
|
"runtimeArgs": ["run", "--project", "backend/Hosts/Ping.Api/Ping.Api.csproj"],
|
||||||
|
"port": 5211
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ping-web",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev", "--prefix", "frontend"],
|
||||||
|
"port": 3100
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
# .NET
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
[Dd]ebug/
|
||||||
|
[Rr]elease/
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
.vs/
|
||||||
|
|
||||||
|
# Rider / VS
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Node / frontend
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Environment files (backend/deploy/.env, frontend/.env.local, ...)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.env
|
||||||
|
!.env.example
|
||||||
|
!.env.prod.example
|
||||||
|
|
||||||
|
# Deploy secrets and runtime state
|
||||||
|
deploy/keys/
|
||||||
|
deploy/secrets/
|
||||||
|
|
||||||
|
# Claude Code local settings
|
||||||
|
.claude/settings.local.json
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
**/.vs/
|
||||||
|
**/.idea/
|
||||||
|
.git/
|
||||||
|
.claude/
|
||||||
|
**/DesignSettings.json
|
||||||
|
*.user
|
||||||
|
Tests/
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+413
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+283
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<Project>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="AWSSDK.S3" Version="4.0.23.4" />
|
||||||
|
<PackageVersion Include="ErrorOr" Version="2.1.1" />
|
||||||
|
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
|
<PackageVersion Include="Mediator.Abstractions" Version="3.0.2" />
|
||||||
|
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.2" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Livekit.Server.Sdk.Dotnet" Version="1.2.3" />
|
||||||
|
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.14.0" />
|
||||||
|
<PackageVersion Include="Microsoft.OpenApi" Version="2.9.0" />
|
||||||
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.14.14" />
|
||||||
|
|
||||||
|
<!-- Test-only -->
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.13" />
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageVersion Include="xunit.v3" Version="4.0.0" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
||||||
|
<PackageVersion Include="Testcontainers" Version="4.15.0" />
|
||||||
|
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.15.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Ping.Api.Authentication;
|
||||||
|
|
||||||
|
public sealed class AuthenticationConfiguration
|
||||||
|
{
|
||||||
|
public const string SectionName = "Authentication";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Url]
|
||||||
|
public required string MetadataAddress { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required string IssuerAddress { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required string AudienceAddress { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required string ClientId { get; set; }
|
||||||
|
|
||||||
|
public string? ClientSecret { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Url]
|
||||||
|
public required string AuthorizationEndpoint { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Url]
|
||||||
|
public required string TokenEndpoint { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MinLength(1)]
|
||||||
|
public required string[] Scopes { get; set; }
|
||||||
|
|
||||||
|
public string? ScalarRedirectUri { get; set; }
|
||||||
|
|
||||||
|
public bool RequireHttpsMetadata { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Ping.Api.Authentication;
|
||||||
|
|
||||||
|
public static class AuthenticationExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddAuthenticationConfiguration(
|
||||||
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services
|
||||||
|
.AddOptionsWithValidateOnStart<AuthenticationConfiguration>()
|
||||||
|
.Bind(configuration.GetRequiredSection(AuthenticationConfiguration.SectionName))
|
||||||
|
.ValidateDataAnnotations();
|
||||||
|
|
||||||
|
services.AddSingleton(serviceProvider =>
|
||||||
|
serviceProvider.GetRequiredService<IOptions<AuthenticationConfiguration>>().Value);
|
||||||
|
|
||||||
|
services.AddMemoryCache();
|
||||||
|
services.AddHttpClient();
|
||||||
|
services.AddScoped<JwtBearerEventsHandler>();
|
||||||
|
services.ConfigureOptions<JwtBearerOptionsSetup>();
|
||||||
|
|
||||||
|
services
|
||||||
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
.AddJwtBearer();
|
||||||
|
|
||||||
|
services.AddAuthorization();
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Ping.Api.Authentication;
|
||||||
|
|
||||||
|
public sealed class CurrentUser : IBindableFromHttpContext<CurrentUser>
|
||||||
|
{
|
||||||
|
private CurrentUser(ClaimsPrincipal principal)
|
||||||
|
{
|
||||||
|
Principal = principal;
|
||||||
|
IsAuthenticated = principal.Identity?.IsAuthenticated ?? false;
|
||||||
|
|
||||||
|
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (long.TryParse(rawId, out var id) && id != 0)
|
||||||
|
Id = id;
|
||||||
|
|
||||||
|
Username = principal.FindFirstValue(ClaimTypes.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClaimsPrincipal Principal { get; }
|
||||||
|
|
||||||
|
public bool IsAuthenticated { get; }
|
||||||
|
|
||||||
|
public long? Id { get; }
|
||||||
|
|
||||||
|
public string? Username { get; }
|
||||||
|
|
||||||
|
public long RequiredId => Id ?? throw new InvalidOperationException("User Id claim is missing or invalid.");
|
||||||
|
|
||||||
|
public static ValueTask<CurrentUser?> BindAsync(HttpContext context, ParameterInfo parameter) =>
|
||||||
|
ValueTask.FromResult<CurrentUser?>(new CurrentUser(context.User));
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Ping.Application.Users;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Ping.Api.Authentication;
|
||||||
|
|
||||||
|
public sealed class JwtBearerEventsHandler(
|
||||||
|
IMediator mediator,
|
||||||
|
IMemoryCache cache,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
AuthenticationConfiguration authenticationConfiguration,
|
||||||
|
ILogger<JwtBearerEventsHandler> logger) : JwtBearerEvents
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
private sealed record UserInfoResponse(
|
||||||
|
[property: JsonPropertyName("name")] string? Name,
|
||||||
|
[property: JsonPropertyName("preferred_username")] string? PreferredUsername,
|
||||||
|
[property: JsonPropertyName("email")] string? Email,
|
||||||
|
[property: JsonPropertyName("picture")] string? Picture);
|
||||||
|
|
||||||
|
public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext)
|
||||||
|
{
|
||||||
|
var principal = tokenValidatedContext.Principal!;
|
||||||
|
|
||||||
|
var rawId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
if (!long.TryParse(rawId, out var userId))
|
||||||
|
{
|
||||||
|
logger.LogWarning("Token subject '{Subject}' is not a numeric id; skipping user provisioning", rawId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cacheKey = $"user-synced:{userId}";
|
||||||
|
if (cache.TryGetValue(cacheKey, out _))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var userInfo = await FetchUserInfoAsync(tokenValidatedContext, tokenValidatedContext.HttpContext.RequestAborted);
|
||||||
|
|
||||||
|
var username = userInfo?.PreferredUsername
|
||||||
|
?? userInfo?.Name
|
||||||
|
?? principal.FindFirstValue("preferred_username")
|
||||||
|
?? principal.FindFirstValue("name")
|
||||||
|
?? principal.FindFirstValue(ClaimTypes.Name)
|
||||||
|
?? userInfo?.Email
|
||||||
|
?? principal.FindFirstValue("email")
|
||||||
|
?? rawId!;
|
||||||
|
|
||||||
|
var displayName = userInfo?.Name ?? principal.FindFirstValue("name");
|
||||||
|
var avatarUrl = userInfo?.Picture ?? principal.FindFirstValue("picture");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await mediator.Send(
|
||||||
|
new SyncUserCommand(userId, username, displayName, avatarUrl),
|
||||||
|
tokenValidatedContext.HttpContext.RequestAborted);
|
||||||
|
|
||||||
|
cache.Set(cacheKey, true, UserSyncCacheTtl);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Failed to sync user {UserId} during token validation", userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<UserInfoResponse?> FetchUserInfoAsync(
|
||||||
|
TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var authorizationHeader = tokenValidatedContext.HttpContext.Request.Headers.Authorization.ToString();
|
||||||
|
if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var accessToken = authorizationHeader["Bearer ".Length..].Trim();
|
||||||
|
if (accessToken.Length == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = httpClientFactory.CreateClient();
|
||||||
|
var request = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo")
|
||||||
|
{
|
||||||
|
Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) },
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(request, cancellationToken);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<UserInfoResponse>(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Failed to fetch userinfo from identity provider");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task AuthenticationFailed(AuthenticationFailedContext context)
|
||||||
|
{
|
||||||
|
logger.LogWarning(context.Exception, "JWT authentication failed");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task Challenge(JwtBearerChallengeContext context)
|
||||||
|
{
|
||||||
|
if (context.AuthenticateFailure is not null)
|
||||||
|
logger.LogWarning("JWT challenge: {Error} - {Description}", context.Error, context.ErrorDescription);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task MessageReceived(MessageReceivedContext context)
|
||||||
|
{
|
||||||
|
var accessToken = context.Request.Query["access_token"];
|
||||||
|
var path = context.HttpContext.Request.Path;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
|
||||||
|
context.Token = accessToken;
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace Ping.Api.Authentication;
|
||||||
|
|
||||||
|
public sealed class JwtBearerOptionsSetup(IOptions<AuthenticationConfiguration> options)
|
||||||
|
: IConfigureNamedOptions<JwtBearerOptions>
|
||||||
|
{
|
||||||
|
private readonly AuthenticationConfiguration _configuration = options.Value;
|
||||||
|
|
||||||
|
public void Configure(string? name, JwtBearerOptions options)
|
||||||
|
{
|
||||||
|
if (name != JwtBearerDefaults.AuthenticationScheme)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Configure(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Configure(JwtBearerOptions options)
|
||||||
|
{
|
||||||
|
options.MetadataAddress = _configuration.MetadataAddress;
|
||||||
|
options.RequireHttpsMetadata = _configuration.RequireHttpsMetadata;
|
||||||
|
options.Audience = _configuration.AudienceAddress;
|
||||||
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidIssuer = _configuration.IssuerAddress,
|
||||||
|
ValidAudience = _configuration.AudienceAddress,
|
||||||
|
RoleClaimType = ClaimTypes.Role,
|
||||||
|
NameClaimType = ClaimTypes.Name,
|
||||||
|
};
|
||||||
|
options.EventsType = typeof(JwtBearerEventsHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
namespace Ping.Api.DataTransferObjects.Attachments;
|
||||||
|
|
||||||
|
public record RequestAttachmentUploadUrlRequest(string FileName, string ContentType);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using Ping.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Ping.Api.DataTransferObjects.Channels;
|
||||||
|
|
||||||
|
public record CreateChannelRequest(string Name, ChannelType Type);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Ping.Application.Serialization;
|
||||||
|
|
||||||
|
namespace Ping.Api.DataTransferObjects.Friends;
|
||||||
|
|
||||||
|
public record SendFriendRequestRequest([property: JsonConverter(typeof(LongAsStringConverter))] long AddresseeId);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace Ping.Api.DataTransferObjects.Messages;
|
||||||
|
|
||||||
|
public record EditMessageRequest(string Content);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using Ping.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Ping.Api.DataTransferObjects.Messages;
|
||||||
|
|
||||||
|
public record MessageAttachmentRequest(string Key, string FileName, string ContentType, AttachmentKind Kind);
|
||||||
|
|
||||||
|
public record SendMessageRequest(string? Content, Guid? ReplyToMessageId, IReadOnlyList<MessageAttachmentRequest>? Attachments);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
namespace Ping.Api.DataTransferObjects.Servers;
|
||||||
|
|
||||||
|
public record CreateServerRequest(string Name);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Ping.Api.Authentication;
|
||||||
|
using Ping.Api.DataTransferObjects.Attachments;
|
||||||
|
using Ping.Api.Filters;
|
||||||
|
using Ping.Application.Attachments;
|
||||||
|
using Ping.Application.Attachments.Responses;
|
||||||
|
|
||||||
|
namespace Ping.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class AttachmentEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapAttachmentEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/attachments")
|
||||||
|
.WithTags("Attachments")
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapPost("/upload-url", RequestUploadUrl)
|
||||||
|
.WithName("RequestAttachmentUploadUrl")
|
||||||
|
.WithSummary("Request A Presigned Url To Upload A File Or Voice Message Directly To Storage.")
|
||||||
|
.AddEndpointFilter<ValidationFilter<RequestAttachmentUploadUrlRequest>>()
|
||||||
|
.Produces<AttachmentUploadResponse>()
|
||||||
|
.ProducesValidationProblem();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<AttachmentUploadResponse> RequestUploadUrl(
|
||||||
|
IMediator mediator, CurrentUser currentUser, RequestAttachmentUploadUrlRequest request, CancellationToken cancellationToken) =>
|
||||||
|
await mediator.Send(new RequestAttachmentUploadUrlCommand(currentUser.RequiredId, request.FileName, request.ContentType), cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Ping.Api.Authentication;
|
||||||
|
using Ping.Api.Extensions;
|
||||||
|
using Ping.Application.Calls;
|
||||||
|
|
||||||
|
namespace Ping.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class CallEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapCallEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/channels/{channelId:guid}/call-token")
|
||||||
|
.WithTags("Calls")
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapPost("/", RequestCallToken)
|
||||||
|
.WithName("RequestCallToken")
|
||||||
|
.WithSummary("Request A Livekit Access Token To Join The Voice/Video Call For A Channel.")
|
||||||
|
.Produces<Application.Calls.Responses.CallTokenResponse>()
|
||||||
|
.ProducesValidationProblem()
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> RequestCallToken(
|
||||||
|
IMediator mediator, CurrentUser currentUser, Guid channelId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new RequestCallTokenCommand(currentUser.RequiredId, channelId), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Ping.Api.Authentication;
|
||||||
|
using Ping.Api.DataTransferObjects.Channels;
|
||||||
|
using Ping.Api.Extensions;
|
||||||
|
using Ping.Api.Filters;
|
||||||
|
using Ping.Application.Channels;
|
||||||
|
using Ping.Application.Channels.Responses;
|
||||||
|
|
||||||
|
namespace Ping.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class ChannelEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var servers = app.MapGroup("/servers/{serverId:guid}/channels")
|
||||||
|
.WithTags("Channels")
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
servers.MapGet("/", GetServerChannels)
|
||||||
|
.WithName("GetServerChannels")
|
||||||
|
.WithSummary("Get A Server'S Channels.")
|
||||||
|
.Produces<IEnumerable<ChannelResponse>>()
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
servers.MapPost("/", CreateChannel)
|
||||||
|
.WithName("CreateChannel")
|
||||||
|
.WithSummary("Create A Text Or Voice Channel In A Server.")
|
||||||
|
.AddEndpointFilter<ValidationFilter<CreateChannelRequest>>()
|
||||||
|
.Produces<ChannelResponse>(StatusCodes.Status201Created)
|
||||||
|
.ProducesValidationProblem()
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
var directMessages = app.MapGroup("/channels/dm")
|
||||||
|
.WithTags("Channels")
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
directMessages.MapPost("/{otherUserId:long}", GetOrCreateDirectMessageChannel)
|
||||||
|
.WithName("GetOrCreateDirectMessageChannel")
|
||||||
|
.WithSummary("Get Or Create A Direct Message Channel With A Friend.")
|
||||||
|
.Produces<ChannelResponse>()
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
|
.ProducesValidationProblem();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetServerChannels(
|
||||||
|
IMediator mediator, CurrentUser currentUser, Guid serverId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetServerChannelsQuery(currentUser.RequiredId, serverId), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateChannel(
|
||||||
|
IMediator mediator, CurrentUser currentUser, Guid serverId, CreateChannelRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(
|
||||||
|
new CreateChannelCommand(currentUser.RequiredId, serverId, request.Name, request.Type), cancellationToken);
|
||||||
|
return result.ToCreatedResult(channel => $"/servers/{serverId}/channels/{channel.Id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetOrCreateDirectMessageChannel(
|
||||||
|
IMediator mediator, CurrentUser currentUser, long otherUserId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(
|
||||||
|
new GetOrCreateDirectMessageChannelCommand(currentUser.RequiredId, otherUserId), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Ping.Api.Authentication;
|
||||||
|
using Ping.Api.DataTransferObjects.Friends;
|
||||||
|
using Ping.Api.Extensions;
|
||||||
|
using Ping.Api.Filters;
|
||||||
|
using Ping.Application.Friends;
|
||||||
|
using Ping.Application.Friends.Responses;
|
||||||
|
|
||||||
|
namespace Ping.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class FriendEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapFriendEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/friends")
|
||||||
|
.WithTags("Friends")
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/", GetFriends)
|
||||||
|
.WithName("GetFriends")
|
||||||
|
.WithSummary("Get The Current User'S Accepted Friends.")
|
||||||
|
.Produces<IEnumerable<FriendshipResponse>>();
|
||||||
|
|
||||||
|
group.MapGet("/requests", GetPendingFriendRequests)
|
||||||
|
.WithName("GetPendingFriendRequests")
|
||||||
|
.WithSummary("Get Incoming And Outgoing Pending Friend Requests.")
|
||||||
|
.Produces<IEnumerable<FriendshipResponse>>();
|
||||||
|
|
||||||
|
group.MapPost("/requests", SendFriendRequest)
|
||||||
|
.WithName("SendFriendRequest")
|
||||||
|
.WithSummary("Send A Friend Request.")
|
||||||
|
.AddEndpointFilter<ValidationFilter<SendFriendRequestRequest>>()
|
||||||
|
.Produces<FriendshipResponse>(StatusCodes.Status201Created)
|
||||||
|
.ProducesValidationProblem()
|
||||||
|
.Produces(StatusCodes.Status404NotFound)
|
||||||
|
.Produces(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
|
group.MapPost("/requests/{id:guid}/accept", AcceptFriendRequest)
|
||||||
|
.WithName("AcceptFriendRequest")
|
||||||
|
.WithSummary("Accept A Pending Friend Request.")
|
||||||
|
.Produces<FriendshipResponse>()
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
group.MapPost("/requests/{id:guid}/reject", RejectFriendRequest)
|
||||||
|
.WithName("RejectFriendRequest")
|
||||||
|
.WithSummary("Reject A Pending Friend Request.")
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
group.MapDelete("/{id:guid}", RemoveFriend)
|
||||||
|
.WithName("RemoveFriend")
|
||||||
|
.WithSummary("Remove An Existing Friend.")
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
group.MapPost("/block/{userId:long}", BlockUser)
|
||||||
|
.WithName("BlockUser")
|
||||||
|
.WithSummary("Block A User.")
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.ProducesValidationProblem();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IEnumerable<FriendshipResponse>> GetFriends(
|
||||||
|
IMediator mediator, CurrentUser currentUser, CancellationToken cancellationToken) =>
|
||||||
|
await mediator.Send(new GetFriendsQuery(currentUser.RequiredId), cancellationToken);
|
||||||
|
|
||||||
|
private static async Task<IEnumerable<FriendshipResponse>> GetPendingFriendRequests(
|
||||||
|
IMediator mediator, CurrentUser currentUser, CancellationToken cancellationToken) =>
|
||||||
|
await mediator.Send(new GetPendingFriendRequestsQuery(currentUser.RequiredId), cancellationToken);
|
||||||
|
|
||||||
|
private static async Task<IResult> SendFriendRequest(
|
||||||
|
IMediator mediator, CurrentUser currentUser, SendFriendRequestRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new SendFriendRequestCommand(currentUser.RequiredId, request.AddresseeId), cancellationToken);
|
||||||
|
return result.ToCreatedResult(friendship => $"/friends/requests/{friendship.Id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> AcceptFriendRequest(
|
||||||
|
IMediator mediator, CurrentUser currentUser, Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new AcceptFriendRequestCommand(currentUser.RequiredId, id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> RejectFriendRequest(
|
||||||
|
IMediator mediator, CurrentUser currentUser, Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new RejectFriendRequestCommand(currentUser.RequiredId, id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> RemoveFriend(
|
||||||
|
IMediator mediator, CurrentUser currentUser, Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new RemoveFriendCommand(currentUser.RequiredId, id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> BlockUser(
|
||||||
|
IMediator mediator, CurrentUser currentUser, long userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new BlockUserCommand(currentUser.RequiredId, userId), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Ping.Api.Authentication;
|
||||||
|
using Ping.Api.DataTransferObjects.Messages;
|
||||||
|
using Ping.Api.Extensions;
|
||||||
|
using Ping.Api.Filters;
|
||||||
|
using Ping.Application.Messages;
|
||||||
|
using Ping.Application.Messages.Responses;
|
||||||
|
|
||||||
|
namespace Ping.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class MessageEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapMessageEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var channelMessages = app.MapGroup("/channels/{channelId:guid}/messages")
|
||||||
|
.WithTags("Messages")
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
channelMessages.MapGet("/", GetChannelMessages)
|
||||||
|
.WithName("GetChannelMessages")
|
||||||
|
.WithSummary("Get A Channel'S Messages, Newest First, Optionally Before A Given Message.")
|
||||||
|
.Produces<IEnumerable<MessageResponse>>()
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
channelMessages.MapPost("/", SendMessage)
|
||||||
|
.WithName("SendMessage")
|
||||||
|
.WithSummary("Send A Message To A Channel.")
|
||||||
|
.AddEndpointFilter<ValidationFilter<SendMessageRequest>>()
|
||||||
|
.Produces<MessageResponse>(StatusCodes.Status201Created)
|
||||||
|
.ProducesValidationProblem()
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
var messages = app.MapGroup("/messages")
|
||||||
|
.WithTags("Messages")
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
messages.MapPatch("/{id:guid}", EditMessage)
|
||||||
|
.WithName("EditMessage")
|
||||||
|
.WithSummary("Edit A Message'S Content.")
|
||||||
|
.AddEndpointFilter<ValidationFilter<EditMessageRequest>>()
|
||||||
|
.Produces<MessageResponse>()
|
||||||
|
.ProducesValidationProblem()
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
messages.MapDelete("/{id:guid}", DeleteMessage)
|
||||||
|
.WithName("DeleteMessage")
|
||||||
|
.WithSummary("Delete A Message (Soft Delete).")
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetChannelMessages(
|
||||||
|
IMediator mediator, CurrentUser currentUser, Guid channelId, Guid? before, int take, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(
|
||||||
|
new GetChannelMessagesQuery(currentUser.RequiredId, channelId, before, take == 0 ? 50 : take), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> SendMessage(
|
||||||
|
IMediator mediator, CurrentUser currentUser, Guid channelId, SendMessageRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var attachments = (request.Attachments ?? [])
|
||||||
|
.Select(a => new PendingAttachment(a.Key, a.FileName, a.ContentType, a.Kind))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var result = await mediator.Send(
|
||||||
|
new SendMessageCommand(currentUser.RequiredId, channelId, request.Content ?? string.Empty, request.ReplyToMessageId, attachments),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return result.ToCreatedResult(message => $"/channels/{channelId}/messages/{message.Id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> EditMessage(
|
||||||
|
IMediator mediator, CurrentUser currentUser, Guid id, EditMessageRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new EditMessageCommand(currentUser.RequiredId, id, request.Content), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> DeleteMessage(
|
||||||
|
IMediator mediator, CurrentUser currentUser, Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new DeleteMessageCommand(currentUser.RequiredId, id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Ping.Api.Authentication;
|
||||||
|
using Ping.Api.DataTransferObjects.Servers;
|
||||||
|
using Ping.Api.Filters;
|
||||||
|
using Ping.Application.Servers;
|
||||||
|
using Ping.Application.Servers.Responses;
|
||||||
|
|
||||||
|
namespace Ping.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class ServerEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapServerEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/servers")
|
||||||
|
.WithTags("Servers")
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/", GetUserServers)
|
||||||
|
.WithName("GetUserServers")
|
||||||
|
.WithSummary("Get The Servers The Current User Is A Member Of.")
|
||||||
|
.Produces<IEnumerable<ServerResponse>>();
|
||||||
|
|
||||||
|
group.MapPost("/", CreateServer)
|
||||||
|
.WithName("CreateServer")
|
||||||
|
.WithSummary("Create A New Server, With The Current User As Owner.")
|
||||||
|
.AddEndpointFilter<ValidationFilter<CreateServerRequest>>()
|
||||||
|
.Produces<ServerResponse>(StatusCodes.Status201Created)
|
||||||
|
.ProducesValidationProblem();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IEnumerable<ServerResponse>> GetUserServers(
|
||||||
|
IMediator mediator, CurrentUser currentUser, CancellationToken cancellationToken) =>
|
||||||
|
await mediator.Send(new GetUserServersQuery(currentUser.RequiredId), cancellationToken);
|
||||||
|
|
||||||
|
private static async Task<IResult> CreateServer(
|
||||||
|
IMediator mediator, CurrentUser currentUser, CreateServerRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var server = await mediator.Send(new CreateServerCommand(currentUser.RequiredId, request.Name), cancellationToken);
|
||||||
|
return Results.Created($"/servers/{server.Id}", server);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Ping.Api.Authentication;
|
||||||
|
using Ping.Api.Extensions;
|
||||||
|
using Ping.Application.Users;
|
||||||
|
|
||||||
|
namespace Ping.Api.Endpoints;
|
||||||
|
|
||||||
|
public static class UserEndpoints
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/users")
|
||||||
|
.WithTags("Users")
|
||||||
|
.RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/me", GetCurrentUser)
|
||||||
|
.WithName("GetCurrentUser")
|
||||||
|
.WithSummary("Get The Currently Authenticated User.")
|
||||||
|
.Produces<Application.Users.Responses.UserResponse>()
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
group.MapGet("/{id:long}", GetUserById)
|
||||||
|
.WithName("GetUserById")
|
||||||
|
.WithSummary("Get A User By Id.")
|
||||||
|
.Produces<Application.Users.Responses.UserResponse>()
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
group.MapGet("/search", SearchUsers)
|
||||||
|
.WithName("SearchUsers")
|
||||||
|
.WithSummary("Search Users By Username, Excluding The Current User.")
|
||||||
|
.Produces<IEnumerable<Application.Users.Responses.UserResponse>>();
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetCurrentUser(IMediator mediator, CurrentUser currentUser, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetUserByIdQuery(currentUser.RequiredId), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetUserById(IMediator mediator, long id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetUserByIdQuery(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IEnumerable<Application.Users.Responses.UserResponse>> SearchUsers(
|
||||||
|
IMediator mediator, CurrentUser currentUser, string query, CancellationToken cancellationToken) =>
|
||||||
|
await mediator.Send(new SearchUsersQuery(query, currentUser.RequiredId), cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using IResult = Microsoft.AspNetCore.Http.IResult;
|
||||||
|
|
||||||
|
namespace Ping.Api.Extensions;
|
||||||
|
|
||||||
|
public static class ErrorOrHttpExtensions
|
||||||
|
{
|
||||||
|
public static IResult ToHttpResult<T>(this ErrorOr<T> result)
|
||||||
|
{
|
||||||
|
if (result.IsError)
|
||||||
|
return Problem(result.Errors);
|
||||||
|
|
||||||
|
return result.Value is Success
|
||||||
|
? Results.NoContent()
|
||||||
|
: Results.Ok(result.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IResult ToCreatedResult<T>(this ErrorOr<T> result, Func<T, string> locationFactory) =>
|
||||||
|
result.IsError
|
||||||
|
? Problem(result.Errors)
|
||||||
|
: Results.Created(locationFactory(result.Value), result.Value);
|
||||||
|
|
||||||
|
private static IResult Problem(List<Error> errors)
|
||||||
|
{
|
||||||
|
if (errors.All(e => e.Type == ErrorType.Validation))
|
||||||
|
return Results.ValidationProblem(ToValidationDictionary(errors));
|
||||||
|
|
||||||
|
var error = errors[0];
|
||||||
|
return error.Type switch
|
||||||
|
{
|
||||||
|
ErrorType.NotFound => Results.NotFound(error.Description),
|
||||||
|
ErrorType.Unauthorized => Results.Unauthorized(),
|
||||||
|
ErrorType.Forbidden => Results.StatusCode(StatusCodes.Status403Forbidden),
|
||||||
|
ErrorType.Conflict => Results.Conflict(error.Description),
|
||||||
|
_ => Results.Problem(error.Description),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string[]> ToValidationDictionary(IEnumerable<Error> errors) =>
|
||||||
|
errors
|
||||||
|
.GroupBy(e => string.IsNullOrEmpty(e.Code) ? "general" : e.Code)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Select(e => e.Description).ToArray());
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Ping.Api.Authentication;
|
||||||
|
using Ping.Api.DataTransferObjects.Servers;
|
||||||
|
using Ping.Api.Hubs;
|
||||||
|
using Ping.Api.OpenApi;
|
||||||
|
using Ping.Api.Scalar;
|
||||||
|
using Ping.Application;
|
||||||
|
using Ping.Application.Contracts;
|
||||||
|
using Ping.Infrastructure.Persistence;
|
||||||
|
using Ping.Infrastructure.Services;
|
||||||
|
|
||||||
|
namespace Ping.Api.Extensions;
|
||||||
|
|
||||||
|
public static class ServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
services.AddApplication(configuration);
|
||||||
|
services.AddDatabase(configuration);
|
||||||
|
services.AddStorageService(configuration);
|
||||||
|
services.AddCallTokenService(configuration);
|
||||||
|
services.AddAuthenticationConfiguration(configuration);
|
||||||
|
services.AddOpenApiConfiguration();
|
||||||
|
services.AddScalarConfiguration();
|
||||||
|
services.AddValidatorsFromAssemblyContaining<CreateServerRequest>();
|
||||||
|
|
||||||
|
services.AddSignalR();
|
||||||
|
services.AddScoped<IRealtimeNotifier, RealtimeNotifier>();
|
||||||
|
services.AddSingleton<PresenceTracker>();
|
||||||
|
services.AddSingleton<IPresenceTracker>(sp => sp.GetRequiredService<PresenceTracker>());
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Ping.Api.Filters;
|
||||||
|
|
||||||
|
public sealed class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter where T : class
|
||||||
|
{
|
||||||
|
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
|
||||||
|
{
|
||||||
|
var argument = context.Arguments.OfType<T>().FirstOrDefault();
|
||||||
|
|
||||||
|
if (argument is null)
|
||||||
|
return await next(context);
|
||||||
|
|
||||||
|
var result = await validator.ValidateAsync(argument, context.HttpContext.RequestAborted);
|
||||||
|
|
||||||
|
return result.IsValid
|
||||||
|
? await next(context)
|
||||||
|
: Results.ValidationProblem(result.ToDictionary());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Ping.Application.Contracts;
|
||||||
|
using Ping.Application.Shared;
|
||||||
|
using Ping.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Ping.Api.Hubs;
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
public sealed class ChatHub(IDatabase database, PresenceTracker presence) : Hub
|
||||||
|
{
|
||||||
|
public override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
if (presence.AddConnection(GetUserId()))
|
||||||
|
await BroadcastPresenceAsync(GetUserId());
|
||||||
|
|
||||||
|
await base.OnConnectedAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task OnDisconnectedAsync(Exception? exception)
|
||||||
|
{
|
||||||
|
if (presence.RemoveConnection(GetUserId()))
|
||||||
|
await BroadcastPresenceAsync(GetUserId());
|
||||||
|
|
||||||
|
await base.OnDisconnectedAsync(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetBusy(bool busy)
|
||||||
|
{
|
||||||
|
var userId = GetUserId();
|
||||||
|
presence.SetBusy(userId, busy);
|
||||||
|
await BroadcastPresenceAsync(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BroadcastPresenceAsync(long userId)
|
||||||
|
{
|
||||||
|
var friendIds = await database.Friendships.AsNoTracking()
|
||||||
|
.Where(f => f.Status == FriendshipStatus.Accepted && (f.RequesterId == userId || f.AddresseeId == userId))
|
||||||
|
.Select(f => f.RequesterId == userId ? f.AddresseeId : f.RequesterId)
|
||||||
|
.ToListAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
if (friendIds.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await Clients.Users(friendIds.Select(id => id.ToString()))
|
||||||
|
.SendAsync("PresenceChanged", userId.ToString(), presence.GetStatus(userId).ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task JoinChannel(Guid channelId)
|
||||||
|
{
|
||||||
|
var hasAccess = await database.HasAccessAsync(channelId, GetUserId(), Context.ConnectionAborted);
|
||||||
|
if (!hasAccess)
|
||||||
|
throw new HubException("Channel not found");
|
||||||
|
|
||||||
|
await Groups.AddToGroupAsync(Context.ConnectionId, GroupName(channelId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task LeaveChannel(Guid channelId) =>
|
||||||
|
Groups.RemoveFromGroupAsync(Context.ConnectionId, GroupName(channelId));
|
||||||
|
|
||||||
|
public Task Typing(Guid channelId) =>
|
||||||
|
Clients.OthersInGroup(GroupName(channelId)).SendAsync("Typing", GetUserId(), channelId);
|
||||||
|
|
||||||
|
public async Task RingUser(Guid channelId, string calleeId)
|
||||||
|
{
|
||||||
|
var callerId = GetUserId();
|
||||||
|
if (!long.TryParse(calleeId, out var calleeUserId))
|
||||||
|
throw new HubException("Invalid user id");
|
||||||
|
|
||||||
|
var callerHasAccess = await database.HasAccessAsync(channelId, callerId, Context.ConnectionAborted);
|
||||||
|
var calleeHasAccess = await database.HasAccessAsync(channelId, calleeUserId, Context.ConnectionAborted);
|
||||||
|
if (!callerHasAccess || !calleeHasAccess)
|
||||||
|
throw new HubException("Channel not found");
|
||||||
|
|
||||||
|
await Clients.User(calleeId).SendAsync("IncomingCall", channelId, callerId.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task DeclineCall(Guid channelId, string callerId) =>
|
||||||
|
Clients.User(callerId).SendAsync("CallDeclined", channelId);
|
||||||
|
|
||||||
|
public Task CancelCall(Guid channelId, string calleeId) =>
|
||||||
|
Clients.User(calleeId).SendAsync("CallCancelled", channelId);
|
||||||
|
|
||||||
|
private long GetUserId()
|
||||||
|
{
|
||||||
|
var rawId = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
|
return long.TryParse(rawId, out var id) ? id : throw new HubException("User Id claim is missing or invalid");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string GroupName(Guid channelId) => $"channel:{channelId}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using Ping.Application.Contracts;
|
||||||
|
|
||||||
|
namespace Ping.Api.Hubs;
|
||||||
|
|
||||||
|
public sealed class PresenceTracker : IPresenceTracker
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<long, int> _connectionCounts = new();
|
||||||
|
private readonly ConcurrentDictionary<long, byte> _busyUserIds = new();
|
||||||
|
|
||||||
|
public bool AddConnection(long userId) => _connectionCounts.AddOrUpdate(userId, 1, (_, count) => count + 1) == 1;
|
||||||
|
|
||||||
|
public bool RemoveConnection(long userId)
|
||||||
|
{
|
||||||
|
while (_connectionCounts.TryGetValue(userId, out var count))
|
||||||
|
{
|
||||||
|
if (count <= 1)
|
||||||
|
{
|
||||||
|
if (!_connectionCounts.TryRemove(userId, out _))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
_busyUserIds.TryRemove(userId, out _);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_connectionCounts.TryUpdate(userId, count - 1, count))
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetBusy(long userId, bool busy)
|
||||||
|
{
|
||||||
|
if (busy)
|
||||||
|
_busyUserIds[userId] = 0;
|
||||||
|
else
|
||||||
|
_busyUserIds.TryRemove(userId, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PresenceStatus GetStatus(long userId)
|
||||||
|
{
|
||||||
|
if (!_connectionCounts.ContainsKey(userId))
|
||||||
|
return PresenceStatus.Offline;
|
||||||
|
|
||||||
|
return _busyUserIds.ContainsKey(userId) ? PresenceStatus.Busy : PresenceStatus.Online;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user