From 9b4f61fcda339e3af826c9d442f826e0ab9b7b1e Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 16 Nov 2025 21:22:45 +0800 Subject: [PATCH] :sparkles: Embeddable funds :sparkles: Chat message embeddable poll --- DysonNetwork.Pass/Wallet/FundExpirationJob.cs | 3 - DysonNetwork.Pass/Wallet/PaymentService.cs | 16 ++ .../Wallet/PaymentServiceGrpc.cs | 16 +- DysonNetwork.Pass/Wallet/WalletController.cs | 40 ++-- DysonNetwork.Shared/Proto/wallet.proto | 5 + DysonNetwork.Sphere/Chat/ChatController.cs | 172 +++++++++++++++++- DysonNetwork.Sphere/Poll/PollEmbed.cs | 6 - DysonNetwork.Sphere/Poll/PollService.cs | 14 +- DysonNetwork.Sphere/Post/PostController.cs | 90 ++++++++- DysonNetwork.Sphere/Wallet/FundEmbed.cs | 10 + 10 files changed, 322 insertions(+), 50 deletions(-) create mode 100644 DysonNetwork.Sphere/Wallet/FundEmbed.cs diff --git a/DysonNetwork.Pass/Wallet/FundExpirationJob.cs b/DysonNetwork.Pass/Wallet/FundExpirationJob.cs index a91acc4..b11234c 100644 --- a/DysonNetwork.Pass/Wallet/FundExpirationJob.cs +++ b/DysonNetwork.Pass/Wallet/FundExpirationJob.cs @@ -1,6 +1,3 @@ -using DysonNetwork.Shared.Models; -using Microsoft.EntityFrameworkCore; -using NodaTime; using Quartz; namespace DysonNetwork.Pass.Wallet; diff --git a/DysonNetwork.Pass/Wallet/PaymentService.cs b/DysonNetwork.Pass/Wallet/PaymentService.cs index dee39c2..f214f08 100644 --- a/DysonNetwork.Pass/Wallet/PaymentService.cs +++ b/DysonNetwork.Pass/Wallet/PaymentService.cs @@ -680,6 +680,22 @@ public class PaymentService( await db.SaveChangesAsync(); } + public async Task GetWalletFundAsync(Guid fundId) + { + var fund = await db.WalletFunds + .Include(f => f.Recipients) + .ThenInclude(r => r.RecipientAccount) + .ThenInclude(a => a.Profile) + .Include(f => f.CreatorAccount) + .ThenInclude(a => a.Profile) + .FirstOrDefaultAsync(f => f.Id == fundId); + + if (fund == null) + throw new InvalidOperationException("Fund not found"); + + return fund; + } + public async Task GetWalletOverviewAsync(Guid accountId, DateTime? startDate = null, DateTime? endDate = null) { var wallet = await wat.GetWalletAsync(accountId); diff --git a/DysonNetwork.Pass/Wallet/PaymentServiceGrpc.cs b/DysonNetwork.Pass/Wallet/PaymentServiceGrpc.cs index fc2e233..96267c8 100644 --- a/DysonNetwork.Pass/Wallet/PaymentServiceGrpc.cs +++ b/DysonNetwork.Pass/Wallet/PaymentServiceGrpc.cs @@ -8,7 +8,7 @@ namespace DysonNetwork.Pass.Wallet; public class PaymentServiceGrpc(PaymentService paymentService) : Shared.Proto.PaymentService.PaymentServiceBase { - public override async Task CreateOrder( + public override async Task CreateOrder( CreateOrderRequest request, ServerCallContext context ) @@ -31,7 +31,7 @@ public class PaymentServiceGrpc(PaymentService paymentService) return order.ToProtoValue(); } - public override async Task CreateTransactionWithAccount( + public override async Task CreateTransactionWithAccount( CreateTransactionWithAccountRequest request, ServerCallContext context ) @@ -87,7 +87,7 @@ public class PaymentServiceGrpc(PaymentService paymentService) }; } - public override async Task Transfer( + public override async Task Transfer( TransferRequest request, ServerCallContext context ) @@ -100,5 +100,13 @@ public class PaymentServiceGrpc(PaymentService paymentService) ); return transaction.ToProtoValue(); } -} + public override async Task GetWalletFund( + GetWalletFundRequest request, + ServerCallContext context + ) + { + var walletFund = await paymentService.GetWalletFundAsync(Guid.Parse(request.FundId)); + return walletFund.ToProtoValue(); + } +} diff --git a/DysonNetwork.Pass/Wallet/WalletController.cs b/DysonNetwork.Pass/Wallet/WalletController.cs index cc2246a..70467f8 100644 --- a/DysonNetwork.Pass/Wallet/WalletController.cs +++ b/DysonNetwork.Pass/Wallet/WalletController.cs @@ -12,7 +12,13 @@ namespace DysonNetwork.Pass.Wallet; [ApiController] [Route("/api/wallets")] -public class WalletController(AppDatabase db, WalletService ws, PaymentService payment, AuthService auth, ICacheService cache) : ControllerBase +public class WalletController( + AppDatabase db, + WalletService ws, + PaymentService payment, + AuthService auth, + ICacheService cache +) : ControllerBase { [HttpPost] [Authorize] @@ -154,7 +160,8 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p var query = db.PaymentOrders.AsQueryable() .Include(o => o.Transaction) - .Where(o => o.Transaction != null && (o.Transaction.PayeeWalletId == accountWallet.Id || o.Transaction.PayerWalletId == accountWallet.Id)) + .Where(o => o.Transaction != null && (o.Transaction.PayeeWalletId == accountWallet.Id || + o.Transaction.PayerWalletId == accountWallet.Id)) .AsQueryable(); var orderCount = await query.CountAsync(); @@ -301,12 +308,12 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p var query = db.WalletFunds .Include(f => f.Recipients) - .ThenInclude(r => r.RecipientAccount) - .ThenInclude(a => a.Profile) + .ThenInclude(r => r.RecipientAccount) + .ThenInclude(a => a.Profile) .Include(f => f.CreatorAccount) - .ThenInclude(a => a.Profile) + .ThenInclude(a => a.Profile) .Where(f => f.CreatorAccountId == currentUser.Id || - f.Recipients.Any(r => r.RecipientAccountId == currentUser.Id)) + f.Recipients.Any(r => r.RecipientAccountId == currentUser.Id)) .AsQueryable(); if (status.HasValue) @@ -326,7 +333,7 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p return Ok(funds); } - [HttpGet("funds/{id}")] + [HttpGet("funds/{id:guid}")] [Authorize] public async Task> GetFund(Guid id) { @@ -334,26 +341,19 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p var fund = await db.WalletFunds .Include(f => f.Recipients) - .ThenInclude(r => r.RecipientAccount) - .ThenInclude(a => a.Profile) + .ThenInclude(r => r.RecipientAccount) + .ThenInclude(a => a.Profile) .Include(f => f.CreatorAccount) - .ThenInclude(a => a.Profile) + .ThenInclude(a => a.Profile) .FirstOrDefaultAsync(f => f.Id == id); - if (fund == null) + if (fund is null) return NotFound("Fund not found"); - // Check if user is creator or recipient - var isCreator = fund.CreatorAccountId == currentUser.Id; - var isRecipient = fund.Recipients.Any(r => r.RecipientAccountId == currentUser.Id); - - if (!isCreator && !isRecipient) - return Forbid("You don't have permission to view this fund"); - return Ok(fund); } - [HttpPost("funds/{id}/receive")] + [HttpPost("funds/{id:guid}/receive")] [Authorize] public async Task> ReceiveFund(Guid id) { @@ -398,4 +398,4 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p return BadRequest(err.Message); } } -} +} \ No newline at end of file diff --git a/DysonNetwork.Shared/Proto/wallet.proto b/DysonNetwork.Shared/Proto/wallet.proto index a5283de..8090d53 100644 --- a/DysonNetwork.Shared/Proto/wallet.proto +++ b/DysonNetwork.Shared/Proto/wallet.proto @@ -191,6 +191,7 @@ service PaymentService { rpc CancelOrder(CancelOrderRequest) returns (Order); rpc RefundOrder(RefundOrderRequest) returns (RefundOrderResponse); rpc Transfer(TransferRequest) returns (Transaction); + rpc GetWalletFund(GetWalletFundRequest) returns (WalletFund); } message CreateOrderRequest { @@ -287,3 +288,7 @@ message TransferRequest { string currency = 3; string amount = 4; } + +message GetWalletFundRequest { + string fund_id = 1; +} diff --git a/DysonNetwork.Sphere/Chat/ChatController.cs b/DysonNetwork.Sphere/Chat/ChatController.cs index 6d49de3..79aa91b 100644 --- a/DysonNetwork.Sphere/Chat/ChatController.cs +++ b/DysonNetwork.Sphere/Chat/ChatController.cs @@ -5,6 +5,10 @@ using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Proto; using DysonNetwork.Sphere.Autocompletion; +using DysonNetwork.Sphere.Poll; +using DysonNetwork.Sphere.Wallet; +using DysonNetwork.Sphere.WebReader; +using Grpc.Core; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -19,7 +23,9 @@ public partial class ChatController( ChatRoomService crs, FileService.FileServiceClient files, AccountService.AccountServiceClient accounts, - AutocompletionService aus + AutocompletionService aus, + PaymentService.PaymentServiceClient paymentClient, + PollService polls ) : ControllerBase { public class MarkMessageReadRequest @@ -66,6 +72,8 @@ public partial class ChatController( { [MaxLength(4096)] public string? Content { get; set; } [MaxLength(36)] public string? Nonce { get; set; } + public Guid? FundId { get; set; } + public Guid? PollId { get; set; } public List? AttachmentsId { get; set; } public Dictionary? Meta { get; set; } public Guid? RepliedMessageId { get; set; } @@ -227,13 +235,52 @@ public partial class ChatController( request.Content = TextSanitizer.Sanitize(request.Content); if (string.IsNullOrWhiteSpace(request.Content) && - (request.AttachmentsId == null || request.AttachmentsId.Count == 0)) + (request.AttachmentsId == null || request.AttachmentsId.Count == 0) && + !request.FundId.HasValue) return BadRequest("You cannot send an empty message."); var member = await crs.GetRoomMember(Guid.Parse(currentUser.Id), roomId); if (member == null || member.Role < ChatMemberRole.Member) return StatusCode(403, "You need to be a normal member to send messages here."); + // Validate fund if provided + if (request.FundId.HasValue) + { + try + { + var fundResponse = await paymentClient.GetWalletFundAsync(new GetWalletFundRequest + { + FundId = request.FundId.Value.ToString() + }); + + // Check if the fund was created by the current user + if (fundResponse.CreatorAccountId != member.AccountId.ToString()) + return BadRequest("You can only share funds that you created."); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return BadRequest("The specified fund does not exist."); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) + { + return BadRequest("Invalid fund ID."); + } + } + + // Validate poll if provided + if (request.PollId.HasValue) + { + try + { + var pollEmbed = await polls.MakePollEmbed(request.PollId.Value); + // Poll validation is handled by the MakePollEmbed method + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + var message = new SnChatMessage { Type = "text", @@ -242,6 +289,36 @@ public partial class ChatController( Nonce = request.Nonce ?? Guid.NewGuid().ToString(), Meta = request.Meta ?? new Dictionary(), }; + + // Add embed for fund if provided + if (request.FundId.HasValue) + { + var fundEmbed = new FundEmbed { Id = request.FundId.Value }; + message.Meta ??= new Dictionary(); + if ( + !message.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + message.Meta["embeds"] = new List>(); + var embeds = (List>)message.Meta["embeds"]; + embeds.Add(EmbeddableBase.ToDictionary(fundEmbed)); + message.Meta["embeds"] = embeds; + } + + // Add embed for poll if provided + if (request.PollId.HasValue) + { + var pollEmbed = await polls.MakePollEmbed(request.PollId.Value); + message.Meta ??= new Dictionary(); + if ( + !message.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + message.Meta["embeds"] = new List>(); + var embeds = (List>)message.Meta["embeds"]; + embeds.Add(EmbeddableBase.ToDictionary(pollEmbed)); + message.Meta["embeds"] = embeds; + } if (request.Content is not null) message.Content = request.Content; if (request.AttachmentsId is not null) @@ -330,6 +407,95 @@ public partial class ChatController( request.ForwardedMessageId, roomId, accountId); message.MembersMentioned = updatedMentions; + // Handle fund embeds for update + if (request.FundId.HasValue) + { + try + { + var fundResponse = await paymentClient.GetWalletFundAsync(new GetWalletFundRequest + { + FundId = request.FundId.Value.ToString() + }); + + // Check if the fund was created by the current user + if (fundResponse.CreatorAccountId != accountId.ToString()) + return BadRequest("You can only share funds that you created."); + + var fundEmbed = new FundEmbed { Id = request.FundId.Value }; + message.Meta ??= new Dictionary(); + if ( + !message.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + message.Meta["embeds"] = new List>(); + var embeds = (List>)message.Meta["embeds"]; + // Remove all old fund embeds + embeds.RemoveAll(e => + e.TryGetValue("type", out var type) && type.ToString() == "fund" + ); + embeds.Add(EmbeddableBase.ToDictionary(fundEmbed)); + message.Meta["embeds"] = embeds; + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return BadRequest("The specified fund does not exist."); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) + { + return BadRequest("Invalid fund ID."); + } + } + else + { + message.Meta ??= new Dictionary(); + if ( + !message.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + message.Meta["embeds"] = new List>(); + var embeds = (List>)message.Meta["embeds"]; + // Remove all old fund embeds + embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "fund"); + } + + // Handle poll embeds for update + if (request.PollId.HasValue) + { + try + { + var pollEmbed = await polls.MakePollEmbed(request.PollId.Value); + message.Meta ??= new Dictionary(); + if ( + !message.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + message.Meta["embeds"] = new List>(); + var embeds = (List>)message.Meta["embeds"]; + // Remove all old poll embeds + embeds.RemoveAll(e => + e.TryGetValue("type", out var type) && type.ToString() == "poll" + ); + embeds.Add(EmbeddableBase.ToDictionary(pollEmbed)); + message.Meta["embeds"] = embeds; + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + else + { + message.Meta ??= new Dictionary(); + if ( + !message.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + message.Meta["embeds"] = new List>(); + var embeds = (List>)message.Meta["embeds"]; + // Remove all old poll embeds + embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "poll"); + } + // Call service method to update the message await cs.UpdateMessageAsync( message, @@ -405,4 +571,4 @@ public partial class ChatController( var result = await aus.GetAutocompletion(request.Content, chatId: roomId, limit: 10); return Ok(result); } -} \ No newline at end of file +} diff --git a/DysonNetwork.Sphere/Poll/PollEmbed.cs b/DysonNetwork.Sphere/Poll/PollEmbed.cs index 1d9ba9c..512dae0 100644 --- a/DysonNetwork.Sphere/Poll/PollEmbed.cs +++ b/DysonNetwork.Sphere/Poll/PollEmbed.cs @@ -31,10 +31,4 @@ public class PollEmbed : EmbeddableBase public override string Type => "poll"; public required Guid Id { get; set; } - - /// - /// Do not store this to the database - /// Only set this when sending the embed - /// - public PollWithStats? Poll { get; set; } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Poll/PollService.cs b/DysonNetwork.Sphere/Poll/PollService.cs index 7405385..c8bd4fc 100644 --- a/DysonNetwork.Sphere/Poll/PollService.cs +++ b/DysonNetwork.Sphere/Poll/PollService.cs @@ -300,18 +300,6 @@ public class PollService(AppDatabase db, ICacheService cache) var poll = await db.Polls .Where(e => e.Id == pollId) .FirstOrDefaultAsync(); - if (poll is null) - throw new Exception("Poll not found"); - return new PollEmbed { Id = poll.Id }; - } - - public async Task LoadPollEmbed(Guid pollId, Guid? accountId) - { - var poll = await GetPoll(pollId) ?? throw new Exception("Poll not found"); - var pollWithStats = PollWithStats.FromPoll(poll); - pollWithStats.Stats = await GetPollStats(poll.Id); - if (accountId is not null) - pollWithStats.UserAnswer = await GetPollAnswer(poll.Id, accountId.Value); - return new PollEmbed { Id = poll.Id, Poll = pollWithStats }; + return poll is null ? throw new Exception("Poll not found") : new PollEmbed { Id = poll.Id }; } } diff --git a/DysonNetwork.Sphere/Post/PostController.cs b/DysonNetwork.Sphere/Post/PostController.cs index 28360da..37983bc 100644 --- a/DysonNetwork.Sphere/Post/PostController.cs +++ b/DysonNetwork.Sphere/Post/PostController.cs @@ -6,7 +6,9 @@ using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Registry; using DysonNetwork.Sphere.Poll; +using DysonNetwork.Sphere.Wallet; using DysonNetwork.Sphere.WebReader; +using Grpc.Core; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -519,6 +521,7 @@ public class PostController( public Guid? RealmId { get; set; } public Guid? PollId { get; set; } + public Guid? FundId { get; set; } } [HttpPost] @@ -536,7 +539,7 @@ public class PostController( var accountId = Guid.Parse(currentUser.Id); - Shared.Models.SnPublisher? publisher; + SnPublisher? publisher; if (pubName is null) { // Use the first personal publisher @@ -629,6 +632,40 @@ public class PostController( } } + if (request.FundId.HasValue) + { + try + { + var fundResponse = await payments.GetWalletFundAsync(new GetWalletFundRequest + { + FundId = request.FundId.Value.ToString() + }); + + // Check if the fund was created by the current user + if (fundResponse.CreatorAccountId != currentUser.Id) + return BadRequest("You can only share funds that you created."); + + var fundEmbed = new FundEmbed { Id = request.FundId.Value }; + post.Meta ??= new Dictionary(); + if ( + !post.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + post.Meta["embeds"] = new List>(); + var embeds = (List>)post.Meta["embeds"]; + embeds.Add(EmbeddableBase.ToDictionary(fundEmbed)); + post.Meta["embeds"] = embeds; + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return BadRequest("The specified fund does not exist."); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) + { + return BadRequest("Invalid fund ID."); + } + } + try { post = await ps.PostAsync( @@ -1072,6 +1109,57 @@ public class PostController( embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "poll"); } + // Handle fund embeds + if (request.FundId.HasValue) + { + try + { + var fundResponse = await payments.GetWalletFundAsync(new GetWalletFundRequest + { + FundId = request.FundId.Value.ToString() + }); + + // Check if the fund was created by the current user + if (fundResponse.CreatorAccountId != currentUser.Id) + return BadRequest("You can only share funds that you created."); + + var fundEmbed = new FundEmbed { Id = request.FundId.Value }; + post.Meta ??= new Dictionary(); + if ( + !post.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + post.Meta["embeds"] = new List>(); + var embeds = (List>)post.Meta["embeds"]; + // Remove all old fund embeds + embeds.RemoveAll(e => + e.TryGetValue("type", out var type) && type.ToString() == "fund" + ); + embeds.Add(EmbeddableBase.ToDictionary(fundEmbed)); + post.Meta["embeds"] = embeds; + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return BadRequest("The specified fund does not exist."); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) + { + return BadRequest("Invalid fund ID."); + } + } + else + { + post.Meta ??= new Dictionary(); + if ( + !post.Meta.TryGetValue("embeds", out var existingEmbeds) + || existingEmbeds is not List + ) + post.Meta["embeds"] = new List>(); + var embeds = (List>)post.Meta["embeds"]; + // Remove all old fund embeds + embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "fund"); + } + // The realm is the same as well as the poll if (request.RealmId is not null) { diff --git a/DysonNetwork.Sphere/Wallet/FundEmbed.cs b/DysonNetwork.Sphere/Wallet/FundEmbed.cs new file mode 100644 index 0000000..d8e7d46 --- /dev/null +++ b/DysonNetwork.Sphere/Wallet/FundEmbed.cs @@ -0,0 +1,10 @@ +using DysonNetwork.Sphere.WebReader; + +namespace DysonNetwork.Sphere.Wallet; + +public class FundEmbed : EmbeddableBase +{ + public override string Type => "fund"; + + public Guid Id { get; set; } +}