diff --git a/backend/src/Modules/Relations/Application/Contacts/DeclineContactRequest.cs b/backend/src/Modules/Relations/Application/Contacts/DeclineContactRequest.cs new file mode 100644 index 0000000..f73298a --- /dev/null +++ b/backend/src/Modules/Relations/Application/Contacts/DeclineContactRequest.cs @@ -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; + +internal sealed class DeclineContactRequestCommandHandler : ICommandHandler +{ + private readonly IContactsDbContext _context; + + public DeclineContactRequestCommandHandler(IContactsDbContext context) + { + _context = context; + } + + public async Task> 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(new Error("Contacts.NotFound", "Contact request not found.")); + } + + contact.Decline(); + await _context.SaveChangesAsync(cancellationToken); + + return Result.Success(true); + } +} diff --git a/backend/src/Modules/Relations/Presentation/Endpoints/ContactsEndpoints.cs b/backend/src/Modules/Relations/Presentation/Endpoints/ContactsEndpoints.cs index 104e6e2..61cf836 100644 --- a/backend/src/Modules/Relations/Presentation/Endpoints/ContactsEndpoints.cs +++ b/backend/src/Modules/Relations/Presentation/Endpoints/ContactsEndpoints.cs @@ -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); diff --git a/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReaction/AddStoryReactionCommand.cs b/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReaction/AddStoryReactionCommand.cs index 9ac539a..1a9efbf 100644 --- a/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReaction/AddStoryReactionCommand.cs +++ b/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReaction/AddStoryReactionCommand.cs @@ -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; + +internal sealed class AddStoryReactionCommandHandler : ICommandHandler +{ + private readonly IStoryRepository _storyRepository; + + public AddStoryReactionCommandHandler(IStoryRepository storyRepository) + { + _storyRepository = storyRepository; + } + + public async Task> Handle(AddStoryReactionCommand request, CancellationToken cancellationToken) + { + var story = await _storyRepository.GetByIdAsync(request.StoryId, cancellationToken); + if (story == null) + { + return Result.Failure(new Error("Stories.NotFound", "Story not found.")); + } + + story.AddReaction(request.UserId, request.Emoji); + await _storyRepository.UpdateAsync(story, cancellationToken); + + return Result.Success(true); + } +} diff --git a/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReply/AddStoryReplyCommand.cs b/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReply/AddStoryReplyCommand.cs index 0ae3d6f..1cdf304 100644 --- a/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReply/AddStoryReplyCommand.cs +++ b/backend/src/Modules/Stories/Application/Stories/Commands/AddStoryReply/AddStoryReplyCommand.cs @@ -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; + +internal sealed class AddStoryReplyCommandHandler : ICommandHandler +{ + private readonly IStoryRepository _storyRepository; + + public AddStoryReplyCommandHandler(IStoryRepository storyRepository) + { + _storyRepository = storyRepository; + } + + public async Task> Handle(AddStoryReplyCommand request, CancellationToken cancellationToken) + { + var story = await _storyRepository.GetByIdAsync(request.StoryId, cancellationToken); + if (story == null) + { + return Result.Failure(new Error("Stories.NotFound", "Story not found.")); + } + + story.AddReply(request.UserId, request.Content); + await _storyRepository.UpdateAsync(story, cancellationToken); + + return Result.Success(true); + } +} diff --git a/backend/src/Modules/Stories/Application/Stories/Commands/RemoveStoryReaction/RemoveStoryReactionCommand.cs b/backend/src/Modules/Stories/Application/Stories/Commands/RemoveStoryReaction/RemoveStoryReactionCommand.cs index 0ae3d6f..8c42649 100644 --- a/backend/src/Modules/Stories/Application/Stories/Commands/RemoveStoryReaction/RemoveStoryReactionCommand.cs +++ b/backend/src/Modules/Stories/Application/Stories/Commands/RemoveStoryReaction/RemoveStoryReactionCommand.cs @@ -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; + +internal sealed class RemoveStoryReactionCommandHandler : ICommandHandler +{ + private readonly IStoryRepository _storyRepository; + + public RemoveStoryReactionCommandHandler(IStoryRepository storyRepository) + { + _storyRepository = storyRepository; + } + + public async Task> Handle(RemoveStoryReactionCommand request, CancellationToken cancellationToken) + { + var story = await _storyRepository.GetByIdAsync(request.StoryId, cancellationToken); + if (story == null) + { + return Result.Failure(new Error("Stories.NotFound", "Story not found.")); + } + + story.RemoveReaction(request.UserId, request.Emoji); + await _storyRepository.UpdateAsync(story, cancellationToken); + + return Result.Success(true); + } +} diff --git a/backend/src/Modules/Stories/Application/Stories/DTOs/StoryReplyDto.cs b/backend/src/Modules/Stories/Application/Stories/DTOs/StoryReplyDto.cs new file mode 100644 index 0000000..028b143 --- /dev/null +++ b/backend/src/Modules/Stories/Application/Stories/DTOs/StoryReplyDto.cs @@ -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 +); diff --git a/backend/src/Modules/Stories/Application/Stories/Queries/GetStoryReplies/GetStoryRepliesQuery.cs b/backend/src/Modules/Stories/Application/Stories/Queries/GetStoryReplies/GetStoryRepliesQuery.cs index 0ae3d6f..c2e517f 100644 --- a/backend/src/Modules/Stories/Application/Stories/Queries/GetStoryReplies/GetStoryRepliesQuery.cs +++ b/backend/src/Modules/Stories/Application/Stories/Queries/GetStoryReplies/GetStoryRepliesQuery.cs @@ -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>; + +internal sealed class GetStoryRepliesQueryHandler : IQueryHandler> +{ + private readonly IStoryRepository _storyRepository; + + public GetStoryRepliesQueryHandler(IStoryRepository storyRepository) + { + _storyRepository = storyRepository; + } + + public async Task>> Handle(GetStoryRepliesQuery request, CancellationToken cancellationToken) + { + var story = await _storyRepository.GetByIdAsync(request.StoryId, cancellationToken); + if (story == null) + { + return Result.Failure>(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); + } +} diff --git a/backend/src/Modules/Stories/Domain/Story.cs b/backend/src/Modules/Stories/Domain/Story.cs index a0e20ae..9c5bbb6 100644 --- a/backend/src/Modules/Stories/Domain/Story.cs +++ b/backend/src/Modules/Stories/Domain/Story.cs @@ -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 { public Guid UserId { get; private set; } @@ -11,6 +29,8 @@ public class Story : Entity public string? BgColor { get; private set; } public DateTime CreatedAt { get; private set; } public int ViewsCount { get; private set; } + public List Reactions { get; private set; } = new(); + public List Replies { get; private set; } = new(); protected Story() : base(Guid.NewGuid()) { } @@ -33,4 +53,33 @@ public class Story : Entity { 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 + }); + } } diff --git a/backend/src/Modules/Stories/Presentation/Endpoints/StoriesEndpoints.cs b/backend/src/Modules/Stories/Presentation/Endpoints/StoriesEndpoints.cs index ab1de90..fab195f 100644 --- a/backend/src/Modules/Stories/Presentation/Endpoints/StoriesEndpoints.cs +++ b/backend/src/Modules/Stories/Presentation/Endpoints/StoriesEndpoints.cs @@ -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(); + }); } } diff --git a/client-web/src/modules/friends/infrastructure/friendApi.ts b/client-web/src/modules/friends/infrastructure/friendApi.ts index 4858a0d..76fc11d 100644 --- a/client-web/src/modules/friends/infrastructure/friendApi.ts +++ b/client-web/src/modules/friends/infrastructure/friendApi.ts @@ -3,37 +3,33 @@ import type { FriendshipStatus, FriendRequest, FriendWithId } from '../../../cor export class FriendApi { static async getFriends() { - return httpClient.request('/friends'); + return httpClient.request('/contacts'); } static async getFriendRequests() { - return httpClient.request('/friends/requests'); - } - - static async getOutgoingRequests() { - return httpClient.request('/friends/outgoing'); + return httpClient.request('/contacts/requests'); } static async getFriendshipStatus(userId: string) { - return httpClient.request(`/friends/status/${userId}`); + return httpClient.request(`/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' }); } }