Embeddable funds

 Chat message embeddable poll
This commit is contained in:
2025-11-16 21:22:45 +08:00
parent 6252988390
commit 9b4f61fcda
10 changed files with 322 additions and 50 deletions

View File

@@ -1,6 +1,3 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Quartz; using Quartz;
namespace DysonNetwork.Pass.Wallet; namespace DysonNetwork.Pass.Wallet;

View File

@@ -680,6 +680,22 @@ public class PaymentService(
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
public async Task<SnWalletFund> 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<WalletOverview> GetWalletOverviewAsync(Guid accountId, DateTime? startDate = null, DateTime? endDate = null) public async Task<WalletOverview> GetWalletOverviewAsync(Guid accountId, DateTime? startDate = null, DateTime? endDate = null)
{ {
var wallet = await wat.GetWalletAsync(accountId); var wallet = await wat.GetWalletAsync(accountId);

View File

@@ -8,7 +8,7 @@ namespace DysonNetwork.Pass.Wallet;
public class PaymentServiceGrpc(PaymentService paymentService) public class PaymentServiceGrpc(PaymentService paymentService)
: Shared.Proto.PaymentService.PaymentServiceBase : Shared.Proto.PaymentService.PaymentServiceBase
{ {
public override async Task<Shared.Proto.Order> CreateOrder( public override async Task<Order> CreateOrder(
CreateOrderRequest request, CreateOrderRequest request,
ServerCallContext context ServerCallContext context
) )
@@ -31,7 +31,7 @@ public class PaymentServiceGrpc(PaymentService paymentService)
return order.ToProtoValue(); return order.ToProtoValue();
} }
public override async Task<Shared.Proto.Transaction> CreateTransactionWithAccount( public override async Task<Transaction> CreateTransactionWithAccount(
CreateTransactionWithAccountRequest request, CreateTransactionWithAccountRequest request,
ServerCallContext context ServerCallContext context
) )
@@ -87,7 +87,7 @@ public class PaymentServiceGrpc(PaymentService paymentService)
}; };
} }
public override async Task<Shared.Proto.Transaction> Transfer( public override async Task<Transaction> Transfer(
TransferRequest request, TransferRequest request,
ServerCallContext context ServerCallContext context
) )
@@ -100,5 +100,13 @@ public class PaymentServiceGrpc(PaymentService paymentService)
); );
return transaction.ToProtoValue(); return transaction.ToProtoValue();
} }
}
public override async Task<WalletFund> GetWalletFund(
GetWalletFundRequest request,
ServerCallContext context
)
{
var walletFund = await paymentService.GetWalletFundAsync(Guid.Parse(request.FundId));
return walletFund.ToProtoValue();
}
}

View File

@@ -12,7 +12,13 @@ namespace DysonNetwork.Pass.Wallet;
[ApiController] [ApiController]
[Route("/api/wallets")] [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] [HttpPost]
[Authorize] [Authorize]
@@ -154,7 +160,8 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
var query = db.PaymentOrders.AsQueryable() var query = db.PaymentOrders.AsQueryable()
.Include(o => o.Transaction) .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(); .AsQueryable();
var orderCount = await query.CountAsync(); var orderCount = await query.CountAsync();
@@ -326,7 +333,7 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
return Ok(funds); return Ok(funds);
} }
[HttpGet("funds/{id}")] [HttpGet("funds/{id:guid}")]
[Authorize] [Authorize]
public async Task<ActionResult<SnWalletFund>> GetFund(Guid id) public async Task<ActionResult<SnWalletFund>> GetFund(Guid id)
{ {
@@ -340,20 +347,13 @@ public class WalletController(AppDatabase db, WalletService ws, PaymentService p
.ThenInclude(a => a.Profile) .ThenInclude(a => a.Profile)
.FirstOrDefaultAsync(f => f.Id == id); .FirstOrDefaultAsync(f => f.Id == id);
if (fund == null) if (fund is null)
return NotFound("Fund not found"); 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); return Ok(fund);
} }
[HttpPost("funds/{id}/receive")] [HttpPost("funds/{id:guid}/receive")]
[Authorize] [Authorize]
public async Task<ActionResult<SnWalletTransaction>> ReceiveFund(Guid id) public async Task<ActionResult<SnWalletTransaction>> ReceiveFund(Guid id)
{ {

View File

@@ -191,6 +191,7 @@ service PaymentService {
rpc CancelOrder(CancelOrderRequest) returns (Order); rpc CancelOrder(CancelOrderRequest) returns (Order);
rpc RefundOrder(RefundOrderRequest) returns (RefundOrderResponse); rpc RefundOrder(RefundOrderRequest) returns (RefundOrderResponse);
rpc Transfer(TransferRequest) returns (Transaction); rpc Transfer(TransferRequest) returns (Transaction);
rpc GetWalletFund(GetWalletFundRequest) returns (WalletFund);
} }
message CreateOrderRequest { message CreateOrderRequest {
@@ -287,3 +288,7 @@ message TransferRequest {
string currency = 3; string currency = 3;
string amount = 4; string amount = 4;
} }
message GetWalletFundRequest {
string fund_id = 1;
}

View File

@@ -5,6 +5,10 @@ using DysonNetwork.Shared.Data;
using DysonNetwork.Shared.Models; using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Sphere.Autocompletion; 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.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -19,7 +23,9 @@ public partial class ChatController(
ChatRoomService crs, ChatRoomService crs,
FileService.FileServiceClient files, FileService.FileServiceClient files,
AccountService.AccountServiceClient accounts, AccountService.AccountServiceClient accounts,
AutocompletionService aus AutocompletionService aus,
PaymentService.PaymentServiceClient paymentClient,
PollService polls
) : ControllerBase ) : ControllerBase
{ {
public class MarkMessageReadRequest public class MarkMessageReadRequest
@@ -66,6 +72,8 @@ public partial class ChatController(
{ {
[MaxLength(4096)] public string? Content { get; set; } [MaxLength(4096)] public string? Content { get; set; }
[MaxLength(36)] public string? Nonce { get; set; } [MaxLength(36)] public string? Nonce { get; set; }
public Guid? FundId { get; set; }
public Guid? PollId { get; set; }
public List<string>? AttachmentsId { get; set; } public List<string>? AttachmentsId { get; set; }
public Dictionary<string, object>? Meta { get; set; } public Dictionary<string, object>? Meta { get; set; }
public Guid? RepliedMessageId { get; set; } public Guid? RepliedMessageId { get; set; }
@@ -227,13 +235,52 @@ public partial class ChatController(
request.Content = TextSanitizer.Sanitize(request.Content); request.Content = TextSanitizer.Sanitize(request.Content);
if (string.IsNullOrWhiteSpace(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."); return BadRequest("You cannot send an empty message.");
var member = await crs.GetRoomMember(Guid.Parse(currentUser.Id), roomId); var member = await crs.GetRoomMember(Guid.Parse(currentUser.Id), roomId);
if (member == null || member.Role < ChatMemberRole.Member) if (member == null || member.Role < ChatMemberRole.Member)
return StatusCode(403, "You need to be a normal member to send messages here."); 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 var message = new SnChatMessage
{ {
Type = "text", Type = "text",
@@ -242,6 +289,36 @@ public partial class ChatController(
Nonce = request.Nonce ?? Guid.NewGuid().ToString(), Nonce = request.Nonce ?? Guid.NewGuid().ToString(),
Meta = request.Meta ?? new Dictionary<string, object>(), Meta = request.Meta ?? new Dictionary<string, object>(),
}; };
// Add embed for fund if provided
if (request.FundId.HasValue)
{
var fundEmbed = new FundEmbed { Id = request.FundId.Value };
message.Meta ??= new Dictionary<string, object>();
if (
!message.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
message.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)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<string, object>();
if (
!message.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
message.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
embeds.Add(EmbeddableBase.ToDictionary(pollEmbed));
message.Meta["embeds"] = embeds;
}
if (request.Content is not null) if (request.Content is not null)
message.Content = request.Content; message.Content = request.Content;
if (request.AttachmentsId is not null) if (request.AttachmentsId is not null)
@@ -330,6 +407,95 @@ public partial class ChatController(
request.ForwardedMessageId, roomId, accountId); request.ForwardedMessageId, roomId, accountId);
message.MembersMentioned = updatedMentions; 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<string, object>();
if (
!message.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
message.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)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<string, object>();
if (
!message.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
message.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)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<string, object>();
if (
!message.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
message.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)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<string, object>();
if (
!message.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
message.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)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 // Call service method to update the message
await cs.UpdateMessageAsync( await cs.UpdateMessageAsync(
message, message,

View File

@@ -31,10 +31,4 @@ public class PollEmbed : EmbeddableBase
public override string Type => "poll"; public override string Type => "poll";
public required Guid Id { get; set; } public required Guid Id { get; set; }
/// <summary>
/// Do not store this to the database
/// Only set this when sending the embed
/// </summary>
public PollWithStats? Poll { get; set; }
} }

View File

@@ -300,18 +300,6 @@ public class PollService(AppDatabase db, ICacheService cache)
var poll = await db.Polls var poll = await db.Polls
.Where(e => e.Id == pollId) .Where(e => e.Id == pollId)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
if (poll is null) return poll is null ? throw new Exception("Poll not found") : new PollEmbed { Id = poll.Id };
throw new Exception("Poll not found");
return new PollEmbed { Id = poll.Id };
}
public async Task<PollEmbed> 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 };
} }
} }

View File

@@ -6,7 +6,9 @@ using DysonNetwork.Shared.Models;
using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Proto;
using DysonNetwork.Shared.Registry; using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Poll; using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.Wallet;
using DysonNetwork.Sphere.WebReader; using DysonNetwork.Sphere.WebReader;
using Grpc.Core;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -519,6 +521,7 @@ public class PostController(
public Guid? RealmId { get; set; } public Guid? RealmId { get; set; }
public Guid? PollId { get; set; } public Guid? PollId { get; set; }
public Guid? FundId { get; set; }
} }
[HttpPost] [HttpPost]
@@ -536,7 +539,7 @@ public class PostController(
var accountId = Guid.Parse(currentUser.Id); var accountId = Guid.Parse(currentUser.Id);
Shared.Models.SnPublisher? publisher; SnPublisher? publisher;
if (pubName is null) if (pubName is null)
{ {
// Use the first personal publisher // 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<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)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 try
{ {
post = await ps.PostAsync( post = await ps.PostAsync(
@@ -1072,6 +1109,57 @@ public class PostController(
embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "poll"); 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<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)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<string, object>();
if (
!post.Meta.TryGetValue("embeds", out var existingEmbeds)
|| existingEmbeds is not List<EmbeddableBase>
)
post.Meta["embeds"] = new List<Dictionary<string, object>>();
var embeds = (List<Dictionary<string, object>>)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 // The realm is the same as well as the poll
if (request.RealmId is not null) if (request.RealmId is not null)
{ {

View File

@@ -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; }
}