Правки

This commit is contained in:
Халимов Рустам
2026-03-30 22:48:35 +03:00
parent cb93ff7240
commit 143c0b7dc4
10 changed files with 349 additions and 26 deletions

View File

@@ -0,0 +1,35 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Relations.Application.Abstractions;
using Knot.Modules.Relations.Domain;
using Knot.Shared.Kernel;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Relations.Application.Contacts;
public record DeclineContactRequestCommand(Guid UserId, Guid RequestId) : ICommand<bool>;
internal sealed class DeclineContactRequestCommandHandler : ICommandHandler<DeclineContactRequestCommand, bool>
{
private readonly IContactsDbContext _context;
public DeclineContactRequestCommandHandler(IContactsDbContext context)
{
_context = context;
}
public async Task<Result<bool>> Handle(DeclineContactRequestCommand request, CancellationToken cancellationToken)
{
var contact = await _context.Contacts.FirstOrDefaultAsync(c => c.Id == request.RequestId, cancellationToken);
if (contact == null || contact.ContactId != request.UserId)
{
return Result.Failure<bool>(new Error("Contacts.NotFound", "Contact request not found."));
}
contact.Decline();
await _context.SaveChangesAsync(cancellationToken);
return Result.Success(true);
}
}

View File

@@ -1,12 +1,14 @@
using Carter;
using Knot.Shared.Kernel;
using Knot.Shared.Infrastructure;
using Knot.Contracts.Relations.Domain;
using Knot.Modules.Relations.Application.Contacts;
using Knot.Shared.Infrastructure;
using Knot.Shared.Kernel;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
namespace Knot.Modules.Relations.Presentation.Endpoints;
@@ -22,6 +24,51 @@ public sealed class ContactsEndpoints : ICarterModule
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
});
group.MapGet("requests", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetIncomingRequestsQuery(userContext.UserId), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
});
group.MapGet("status/{userId:guid}", async (Guid userId, ISender sender, IUserContext userContext, IContactsDbContext context, CancellationToken ct) =>
{
var contact = await context.Contacts
.FirstOrDefaultAsync(c =>
(c.UserId == userContext.UserId && c.ContactId == userId) ||
(c.UserId == userId && c.ContactId == userContext.UserId), ct);
if (contact == null)
{
return Results.Ok(new { status = "none", friendshipId = (string?)null });
}
string status;
string? friendshipId = contact.Id.ToString();
if (contact.UserId == userContext.UserId && contact.Status == ContactStatus.Pending)
{
status = "outgoing";
}
else if (contact.Status == ContactStatus.Pending)
{
status = "pending";
}
else if (contact.Status == ContactStatus.Accepted)
{
status = "accepted";
}
else if (contact.Status == ContactStatus.Declined)
{
status = "declined";
}
else
{
status = "none";
}
return Results.Ok(new { status, friendshipId });
});
group.MapPost("request", async ([FromBody] SendContactRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new SendContactRequestCommand(userContext.UserId, request.ContactId), ct);
@@ -34,6 +81,12 @@ public sealed class ContactsEndpoints : ICarterModule
return result.IsSuccess ? Results.Ok(new { id = result.Value }) : Results.NotFound(result.Error.Description);
});
group.MapPost("{id:guid}/decline", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new DeclineContactRequestCommand(userContext.UserId, id), ct);
return result.IsSuccess ? Results.Ok(new { success = true }) : Results.NotFound(result.Error.Description);
});
group.MapDelete("{id:guid}", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new RemoveContactCommand(userContext.UserId, id), ct);

View File

@@ -1 +1,33 @@
// file removed
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Stories.Application.Abstractions;
using Knot.Shared.Kernel;
namespace Knot.Modules.Stories.Application.Stories.Commands.AddStoryReaction;
public record AddStoryReactionCommand(Guid UserId, Guid StoryId, string Emoji) : ICommand<bool>;
internal sealed class AddStoryReactionCommandHandler : ICommandHandler<AddStoryReactionCommand, bool>
{
private readonly IStoryRepository _storyRepository;
public AddStoryReactionCommandHandler(IStoryRepository storyRepository)
{
_storyRepository = storyRepository;
}
public async Task<Result<bool>> Handle(AddStoryReactionCommand request, CancellationToken cancellationToken)
{
var story = await _storyRepository.GetByIdAsync(request.StoryId, cancellationToken);
if (story == null)
{
return Result.Failure<bool>(new Error("Stories.NotFound", "Story not found."));
}
story.AddReaction(request.UserId, request.Emoji);
await _storyRepository.UpdateAsync(story, cancellationToken);
return Result.Success(true);
}
}

View File

@@ -1 +1,33 @@
// file removed
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Stories.Application.Abstractions;
using Knot.Shared.Kernel;
namespace Knot.Modules.Stories.Application.Stories.Commands.AddStoryReply;
public record AddStoryReplyCommand(Guid UserId, Guid StoryId, string Content) : ICommand<bool>;
internal sealed class AddStoryReplyCommandHandler : ICommandHandler<AddStoryReplyCommand, bool>
{
private readonly IStoryRepository _storyRepository;
public AddStoryReplyCommandHandler(IStoryRepository storyRepository)
{
_storyRepository = storyRepository;
}
public async Task<Result<bool>> Handle(AddStoryReplyCommand request, CancellationToken cancellationToken)
{
var story = await _storyRepository.GetByIdAsync(request.StoryId, cancellationToken);
if (story == null)
{
return Result.Failure<bool>(new Error("Stories.NotFound", "Story not found."));
}
story.AddReply(request.UserId, request.Content);
await _storyRepository.UpdateAsync(story, cancellationToken);
return Result.Success(true);
}
}

View File

@@ -1 +1,33 @@
// file removed
using System;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Stories.Application.Abstractions;
using Knot.Shared.Kernel;
namespace Knot.Modules.Stories.Application.Stories.Commands.RemoveStoryReaction;
public record RemoveStoryReactionCommand(Guid UserId, Guid StoryId, string Emoji) : ICommand<bool>;
internal sealed class RemoveStoryReactionCommandHandler : ICommandHandler<RemoveStoryReactionCommand, bool>
{
private readonly IStoryRepository _storyRepository;
public RemoveStoryReactionCommandHandler(IStoryRepository storyRepository)
{
_storyRepository = storyRepository;
}
public async Task<Result<bool>> Handle(RemoveStoryReactionCommand request, CancellationToken cancellationToken)
{
var story = await _storyRepository.GetByIdAsync(request.StoryId, cancellationToken);
if (story == null)
{
return Result.Failure<bool>(new Error("Stories.NotFound", "Story not found."));
}
story.RemoveReaction(request.UserId, request.Emoji);
await _storyRepository.UpdateAsync(story, cancellationToken);
return Result.Success(true);
}
}

View File

@@ -0,0 +1,11 @@
namespace Knot.Modules.Stories.Application.Stories.DTOs;
public record StoryReplyDto(
Guid Id,
Guid UserId,
string Username,
string DisplayName,
string? Avatar,
string Content,
DateTime CreatedAt
);

View File

@@ -1 +1,42 @@
// file removed
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Knot.Modules.Stories.Application.Abstractions;
using Knot.Modules.Stories.Application.Stories.DTOs;
using Knot.Shared.Kernel;
namespace Knot.Modules.Stories.Application.Stories.Queries.GetStoryReplies;
public record GetStoryRepliesQuery(Guid UserId, Guid StoryId) : IQuery<List<StoryReplyDto>>;
internal sealed class GetStoryRepliesQueryHandler : IQueryHandler<GetStoryRepliesQuery, List<StoryReplyDto>>
{
private readonly IStoryRepository _storyRepository;
public GetStoryRepliesQueryHandler(IStoryRepository storyRepository)
{
_storyRepository = storyRepository;
}
public async Task<Result<List<StoryReplyDto>>> Handle(GetStoryRepliesQuery request, CancellationToken cancellationToken)
{
var story = await _storyRepository.GetByIdAsync(request.StoryId, cancellationToken);
if (story == null)
{
return Result.Failure<List<StoryReplyDto>>(new Error("Stories.NotFound", "Story not found."));
}
var replies = story.Replies.ConvertAll(r => new StoryReplyDto(
r.Id,
r.UserId,
r.Username,
r.DisplayName,
r.Avatar,
r.Content,
r.CreatedAt
));
return Result.Success(replies);
}
}

View File

@@ -1,7 +1,25 @@
using System.Collections.Generic;
using Knot.Shared.Kernel;
namespace Knot.Modules.Stories.Domain;
public class StoryReaction
{
public Guid UserId { get; set; }
public string Emoji { get; set; } = string.Empty;
}
public class StoryReply
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string Username { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string? Avatar { get; set; }
public string Content { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
}
public class Story : Entity<Guid>
{
public Guid UserId { get; private set; }
@@ -11,6 +29,8 @@ public class Story : Entity<Guid>
public string? BgColor { get; private set; }
public DateTime CreatedAt { get; private set; }
public int ViewsCount { get; private set; }
public List<StoryReaction> Reactions { get; private set; } = new();
public List<StoryReply> Replies { get; private set; } = new();
protected Story() : base(Guid.NewGuid()) { }
@@ -33,4 +53,33 @@ public class Story : Entity<Guid>
{
ViewsCount++;
}
public void AddReaction(Guid userId, string emoji)
{
var existing = Reactions.FirstOrDefault(r => r.UserId == userId && r.Emoji == emoji);
if (existing == null)
{
Reactions.Add(new StoryReaction { UserId = userId, Emoji = emoji });
}
}
public void RemoveReaction(Guid userId, string emoji)
{
var reaction = Reactions.FirstOrDefault(r => r.UserId == userId && r.Emoji == emoji);
if (reaction != null)
{
Reactions.Remove(reaction);
}
}
public void AddReply(Guid userId, string content)
{
Replies.Add(new StoryReply
{
Id = Guid.NewGuid(),
UserId = userId,
Content = content,
CreatedAt = DateTime.UtcNow
});
}
}

View File

@@ -1,17 +1,23 @@
using Carter;
using Knot.Modules.Stories.Application.DTOs;
using Knot.Modules.Stories.Application.Stories.Commands.AddStoryReaction;
using Knot.Modules.Stories.Application.Stories.Commands.AddStoryReply;
using Knot.Modules.Stories.Application.Stories.Commands.CreateStory;
using Knot.Modules.Stories.Application.Stories.Commands.DeleteStory;
using Knot.Modules.Stories.Application.Stories.Commands.RemoveStoryReaction;
using Knot.Modules.Stories.Application.Stories.Commands.ViewStory;
using Knot.Modules.Stories.Application.Stories.Queries.GetStories;
using Knot.Modules.Stories.Application.Stories.Queries.GetStoryReplies;
using Knot.Modules.Stories.Application.Stories.Queries.GetStoryViewers;
using Knot.Modules.Stories.Application.Stories.Queries.GetUserStories;
using Knot.Modules.Stories.Domain;
using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage;
using MediatR;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.Mvc;
using Knot.Modules.Stories.Application.Stories.Queries.GetStories;
using Knot.Modules.Stories.Application.Stories.Commands.CreateStory;
using Knot.Modules.Stories.Application.Stories.Queries.GetUserStories;
using Knot.Modules.Stories.Application.Stories.Commands.ViewStory;
using Knot.Modules.Stories.Application.Stories.Queries.GetStoryViewers;
using Knot.Modules.Stories.Application.Stories.Commands.DeleteStory;
using Microsoft.AspNetCore.Routing;
namespace Knot.Modules.Stories.Presentation.Endpoints;
@@ -33,6 +39,18 @@ public sealed class StoriesEndpoints : ICarterModule
return Results.Ok(new { id = result.Value });
});
group.MapPost("video", async (HttpRequest req, ISender sender, IUserContext userContext, IFileStorageService fileStorage, CancellationToken ct) =>
{
if (!req.HasFormContentType) return Results.BadRequest("No file uploaded");
var form = await req.ReadFormAsync(ct);
var file = form.Files.FirstOrDefault();
if (file == null || file.Length == 0) return Results.BadRequest("No file uploaded");
using var stream = file.OpenReadStream();
var fileId = await fileStorage.UploadFileAsync(stream, file.FileName, file.ContentType);
return Results.Ok(new { url = $"/api/files/{fileId}" });
}).DisableAntiforgery();
group.MapGet("user/{userId:guid}", async (Guid userId, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetUserStoriesQuery(userContext.UserId, userId), ct);
@@ -58,6 +76,30 @@ public sealed class StoriesEndpoints : ICarterModule
if (result.IsFailure) return result.Error.Code == "Unauthorized" ? Results.Forbid() : Results.NotFound();
return Results.Ok(result.Value);
});
group.MapPost("{id:guid}/reaction", async (Guid id, [FromBody] AddStoryReactionRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new AddStoryReactionCommand(userContext.UserId, id, request.Emoji), ct);
return result.IsSuccess ? Results.Ok(new { message = "Reaction added" }) : Results.NotFound();
});
group.MapDelete("{id:guid}/reaction", async (Guid id, [FromBody] RemoveStoryReactionRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new RemoveStoryReactionCommand(userContext.UserId, id, request.Emoji), ct);
return result.IsSuccess ? Results.Ok(new { message = "Reaction removed" }) : Results.NotFound();
});
group.MapPost("{id:guid}/reply", async (Guid id, [FromBody] AddStoryReplyRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new AddStoryReplyCommand(userContext.UserId, id, request.Content), ct);
return result.IsSuccess ? Results.Ok(new { message = "Reply added" }) : Results.NotFound();
});
group.MapGet("{id:guid}/replies", async (Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
{
var result = await sender.Send(new GetStoryRepliesQuery(userContext.UserId, id), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
});
}
}

View File

@@ -3,37 +3,33 @@ import type { FriendshipStatus, FriendRequest, FriendWithId } from '../../../cor
export class FriendApi {
static async getFriends() {
return httpClient.request<FriendWithId[]>('/friends');
return httpClient.request<FriendWithId[]>('/contacts');
}
static async getFriendRequests() {
return httpClient.request<FriendRequest[]>('/friends/requests');
}
static async getOutgoingRequests() {
return httpClient.request<FriendRequest[]>('/friends/outgoing');
return httpClient.request<FriendRequest[]>('/contacts/requests');
}
static async getFriendshipStatus(userId: string) {
return httpClient.request<FriendshipStatus>(`/friends/status/${userId}`);
return httpClient.request<FriendshipStatus>(`/contacts/status/${userId}`);
}
static async sendFriendRequest(friendId: string) {
return httpClient.request<{ status: string }>('/friends/request', {
return httpClient.request<{ status: string }>('/contacts/request', {
method: 'POST',
body: JSON.stringify({ friendId }),
body: JSON.stringify({ contactId: friendId }),
});
}
static async acceptFriendRequest(friendshipId: string) {
return httpClient.request<{ id: string }>(`/friends/${friendshipId}/accept`, { method: 'POST' });
return httpClient.request<{ id: string }>(`/contacts/${friendshipId}/accept`, { method: 'POST' });
}
static async declineFriendRequest(friendshipId: string) {
return httpClient.request<{ success: boolean }>(`/friends/${friendshipId}/decline`, { method: 'POST' });
return httpClient.request<{ success: boolean }>(`/contacts/${friendshipId}/decline`, { method: 'POST' });
}
static async removeFriend(friendshipId: string) {
return httpClient.request<{ success: boolean }>(`/friends/${friendshipId}`, { method: 'DELETE' });
return httpClient.request<{ success: boolean }>(`/contacts/${friendshipId}`, { method: 'DELETE' });
}
}