Wallet, payment, developer apps, feature flags of publishers

♻️ Simplified the permission check of chat room, realm, publishers
This commit is contained in:
LittleSheep 2025-05-15 00:26:15 +08:00
parent 9576870373
commit d7d4fde06a
27 changed files with 7468 additions and 124 deletions

View File

@ -1,5 +1,6 @@
using System.Linq.Expressions;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Publisher;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using NodaTime;
@ -43,9 +44,11 @@ public class AppDatabase(
public DbSet<Activity.Activity> Activities { get; set; }
public DbSet<Post.Publisher> Publishers { get; set; }
public DbSet<Post.PublisherMember> PublisherMembers { get; set; }
public DbSet<Post.PublisherSubscription> PublisherSubscriptions { get; set; }
public DbSet<Publisher.Publisher> Publishers { get; set; }
public DbSet<PublisherMember> PublisherMembers { get; set; }
public DbSet<PublisherSubscription> PublisherSubscriptions { get; set; }
public DbSet<PublisherFeature> PublisherFeatures { get; set; }
public DbSet<Post.Post> Posts { get; set; }
public DbSet<Post.PostReaction> PostReactions { get; set; }
public DbSet<Post.PostTag> PostTags { get; set; }
@ -65,6 +68,13 @@ public class AppDatabase(
public DbSet<Sticker.Sticker> Stickers { get; set; }
public DbSet<Sticker.StickerPack> StickerPacks { get; set; }
public DbSet<Wallet.Wallet> Wallets { get; set; }
public DbSet<Wallet.WalletPocket> WalletPockets { get; set; }
public DbSet<Wallet.Order> PaymentOrders { get; set; }
public DbSet<Wallet.Transaction> PaymentTransactions { get; set; }
public DbSet<Developer.CustomApp> CustomApps { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var dataSourceBuilder = new NpgsqlDataSourceBuilder(configuration.GetConnectionString("App"));
@ -138,24 +148,24 @@ public class AppDatabase(
.WithMany(a => a.IncomingRelationships)
.HasForeignKey(r => r.RelatedId);
modelBuilder.Entity<Post.PublisherMember>()
modelBuilder.Entity<PublisherMember>()
.HasKey(pm => new { pm.PublisherId, pm.AccountId });
modelBuilder.Entity<Post.PublisherMember>()
modelBuilder.Entity<PublisherMember>()
.HasOne(pm => pm.Publisher)
.WithMany(p => p.Members)
.HasForeignKey(pm => pm.PublisherId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Post.PublisherMember>()
modelBuilder.Entity<PublisherMember>()
.HasOne(pm => pm.Account)
.WithMany()
.HasForeignKey(pm => pm.AccountId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Post.PublisherSubscription>()
modelBuilder.Entity<PublisherSubscription>()
.HasOne(ps => ps.Publisher)
.WithMany(p => p.Subscriptions)
.HasForeignKey(ps => ps.PublisherId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Post.PublisherSubscription>()
modelBuilder.Entity<PublisherSubscription>()
.HasOne(ps => ps.Account)
.WithMany()
.HasForeignKey(ps => ps.AccountId)

View File

@ -10,7 +10,7 @@ namespace DysonNetwork.Sphere.Chat;
[ApiController]
[Route("/chat")]
public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService crs) : ControllerBase
public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService crs, RealmService rs) : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task<ActionResult<ChatRoom>> GetChatRoom(Guid id)
@ -168,13 +168,9 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
if (request.RealmId is not null)
{
var member = await db.RealmMembers
.Where(m => m.AccountId == currentUser.Id)
.Where(m => m.RealmId == request.RealmId)
.FirstOrDefaultAsync();
if (member is null || member.Role < RealmMemberRole.Moderator)
if (!await rs.IsMemberWithRole(request.RealmId.Value, currentUser.Id, RealmMemberRole.Moderator))
return StatusCode(403, "You need at least be a moderator to create chat linked to the realm.");
chatRoom.RealmId = member.RealmId;
chatRoom.RealmId = request.RealmId;
}
if (request.PictureId is not null)
@ -216,22 +212,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
if (chatRoom.RealmId is not null)
{
var realmMember = await db.RealmMembers
.Where(m => m.AccountId == currentUser.Id)
.Where(m => m.RealmId == chatRoom.RealmId)
.FirstOrDefaultAsync();
if (realmMember is null || realmMember.Role < RealmMemberRole.Moderator)
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, currentUser.Id, RealmMemberRole.Moderator))
return StatusCode(403, "You need at least be a realm moderator to update the chat.");
}
else
{
var chatMember = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id)
.Where(m => m.ChatRoomId == chatRoom.Id)
.FirstOrDefaultAsync();
if (chatMember is null || chatMember.Role < ChatMemberRole.Moderator)
else if (!await crs.IsMemberWithRole(chatRoom.Id, currentUser.Id, ChatMemberRole.Moderator))
return StatusCode(403, "You need at least be a moderator to update the chat.");
}
if (request.RealmId is not null)
{
@ -287,22 +272,11 @@ public class ChatRoomController(AppDatabase db, FileService fs, ChatRoomService
if (chatRoom.RealmId is not null)
{
var realmMember = await db.RealmMembers
.Where(m => m.AccountId == currentUser.Id)
.Where(m => m.RealmId == chatRoom.RealmId)
.FirstOrDefaultAsync();
if (realmMember is null || realmMember.Role < RealmMemberRole.Moderator)
if (!await rs.IsMemberWithRole(chatRoom.RealmId.Value, currentUser.Id, RealmMemberRole.Moderator))
return StatusCode(403, "You need at least be a realm moderator to delete the chat.");
}
else
{
var chatMember = await db.ChatMembers
.Where(m => m.AccountId == currentUser.Id)
.Where(m => m.ChatRoomId == chatRoom.Id)
.FirstOrDefaultAsync();
if (chatMember is null || chatMember.Role < ChatMemberRole.Owner)
else if (!await crs.IsMemberWithRole(chatRoom.Id, currentUser.Id, ChatMemberRole.Owner))
return StatusCode(403, "You need at least be the owner to delete the chat.");
}
db.ChatRooms.Remove(chatRoom);
await db.SaveChangesAsync();

View File

@ -1,12 +1,20 @@
using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Chat;
public class ChatRoomService(NotificationService nty)
public class ChatRoomService(AppDatabase db, NotificationService nty)
{
public async Task SendInviteNotify(ChatMember member)
{
await nty.SendNotification(member.Account, "invites.chats", "New Chat Invitation", null,
$"You just got invited to join {member.ChatRoom.Name}");
}
public async Task<bool> IsMemberWithRole(Guid roomId, Guid accountId, ChatMemberRole requiredRole)
{
var member = await db.ChatMembers
.FirstOrDefaultAsync(m => m.ChatRoomId == roomId && m.AccountId == accountId);
return member?.Role >= requiredRole;
}
}

View File

@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using NodaTime;
namespace DysonNetwork.Sphere.Developer;
public enum CustomAppStatus
{
Developing,
Staging,
Production,
Suspended
}
public class CustomApp : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Slug { get; set; } = null!;
[MaxLength(1024)] public string Name { get; set; } = null!;
public CustomAppStatus Status { get; set; } = CustomAppStatus.Developing;
public Instant? VerifiedAt { get; set; }
[MaxLength(4096)] public string? VerifiedAs { get; set; }
public Guid PublisherId { get; set; }
public Publisher.Publisher Developer { get; set; } = null!;
}

View File

@ -0,0 +1,11 @@
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Mvc;
namespace DysonNetwork.Sphere.Developer;
[ApiController]
[Route("/developers/apps")]
public class CustomAppController(PublisherService ps) : ControllerBase
{
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,168 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class WalletAndPayment : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "wallets",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_wallets", x => x.id);
table.ForeignKey(
name: "fk_wallets_accounts_account_id",
column: x => x.account_id,
principalTable: "accounts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "payment_transactions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
currency = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
amount = table.Column<decimal>(type: "numeric", nullable: false),
remarks = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
type = table.Column<int>(type: "integer", nullable: false),
payer_wallet_id = table.Column<Guid>(type: "uuid", nullable: true),
payee_wallet_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_payment_transactions", x => x.id);
table.ForeignKey(
name: "fk_payment_transactions_wallets_payee_wallet_id",
column: x => x.payee_wallet_id,
principalTable: "wallets",
principalColumn: "id");
table.ForeignKey(
name: "fk_payment_transactions_wallets_payer_wallet_id",
column: x => x.payer_wallet_id,
principalTable: "wallets",
principalColumn: "id");
});
migrationBuilder.CreateTable(
name: "wallet_pockets",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
currency = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
amount = table.Column<decimal>(type: "numeric", nullable: false),
wallet_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_wallet_pockets", x => x.id);
table.ForeignKey(
name: "fk_wallet_pockets_wallets_wallet_id",
column: x => x.wallet_id,
principalTable: "wallets",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "payment_orders",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
status = table.Column<int>(type: "integer", nullable: false),
currency = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
remarks = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
amount = table.Column<decimal>(type: "numeric", nullable: false),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
payee_wallet_id = table.Column<Guid>(type: "uuid", nullable: false),
transaction_id = table.Column<Guid>(type: "uuid", nullable: true),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_payment_orders", x => x.id);
table.ForeignKey(
name: "fk_payment_orders_payment_transactions_transaction_id",
column: x => x.transaction_id,
principalTable: "payment_transactions",
principalColumn: "id");
table.ForeignKey(
name: "fk_payment_orders_wallets_payee_wallet_id",
column: x => x.payee_wallet_id,
principalTable: "wallets",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_payment_orders_payee_wallet_id",
table: "payment_orders",
column: "payee_wallet_id");
migrationBuilder.CreateIndex(
name: "ix_payment_orders_transaction_id",
table: "payment_orders",
column: "transaction_id");
migrationBuilder.CreateIndex(
name: "ix_payment_transactions_payee_wallet_id",
table: "payment_transactions",
column: "payee_wallet_id");
migrationBuilder.CreateIndex(
name: "ix_payment_transactions_payer_wallet_id",
table: "payment_transactions",
column: "payer_wallet_id");
migrationBuilder.CreateIndex(
name: "ix_wallet_pockets_wallet_id",
table: "wallet_pockets",
column: "wallet_id");
migrationBuilder.CreateIndex(
name: "ix_wallets_account_id",
table: "wallets",
column: "account_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "payment_orders");
migrationBuilder.DropTable(
name: "wallet_pockets");
migrationBuilder.DropTable(
name: "payment_transactions");
migrationBuilder.DropTable(
name: "wallets");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,95 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class PublisherFeatures : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "publisher_type",
table: "publishers",
newName: "type");
migrationBuilder.CreateTable(
name: "custom_apps",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
slug = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
name = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
status = table.Column<int>(type: "integer", nullable: false),
verified_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
verified_as = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
publisher_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_custom_apps", x => x.id);
table.ForeignKey(
name: "fk_custom_apps_publishers_publisher_id",
column: x => x.publisher_id,
principalTable: "publishers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "publisher_features",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
flag = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
expired_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
publisher_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_publisher_features", x => x.id);
table.ForeignKey(
name: "fk_publisher_features_publishers_publisher_id",
column: x => x.publisher_id,
principalTable: "publishers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_custom_apps_publisher_id",
table: "custom_apps",
column: "publisher_id");
migrationBuilder.CreateIndex(
name: "ix_publisher_features_publisher_id",
table: "publisher_features",
column: "publisher_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "custom_apps");
migrationBuilder.DropTable(
name: "publisher_features");
migrationBuilder.RenameColumn(
name: "type",
table: "publishers",
newName: "publisher_type");
}
}
}

View File

@ -1140,6 +1140,63 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("chat_realtime_call", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomApp", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("name");
b.Property<Guid>("PublisherId")
.HasColumnType("uuid")
.HasColumnName("publisher_id");
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("slug");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<string>("VerifiedAs")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("verified_as");
b.Property<Instant?>("VerifiedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("verified_at");
b.HasKey("Id")
.HasName("pk_custom_apps");
b.HasIndex("PublisherId")
.HasDatabaseName("ix_custom_apps_publisher_id");
b.ToTable("custom_apps", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroup", b =>
{
b.Property<Guid>("Id")
@ -1565,7 +1622,7 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("post_tags", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b =>
modelBuilder.Entity("DysonNetwork.Sphere.Publisher.Publisher", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@ -1609,14 +1666,14 @@ namespace DysonNetwork.Sphere.Migrations
.HasColumnType("character varying(128)")
.HasColumnName("picture_id");
b.Property<int>("PublisherType")
.HasColumnType("integer")
.HasColumnName("publisher_type");
b.Property<Guid?>("RealmId")
.HasColumnType("uuid")
.HasColumnName("realm_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
@ -1643,7 +1700,49 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("publishers", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.PublisherMember", b =>
modelBuilder.Entity("DysonNetwork.Sphere.Publisher.PublisherFeature", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Flag")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("flag");
b.Property<Guid>("PublisherId")
.HasColumnType("uuid")
.HasColumnName("publisher_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_publisher_features");
b.HasIndex("PublisherId")
.HasDatabaseName("ix_publisher_features_publisher_id");
b.ToTable("publisher_features", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Publisher.PublisherMember", b =>
{
b.Property<Guid>("PublisherId")
.HasColumnType("uuid")
@ -1682,7 +1781,7 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("publisher_members", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.PublisherSubscription", b =>
modelBuilder.Entity("DysonNetwork.Sphere.Publisher.PublisherSubscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
@ -2049,6 +2148,200 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("files", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Order", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("amount");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("currency");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Guid>("PayeeWalletId")
.HasColumnType("uuid")
.HasColumnName("payee_wallet_id");
b.Property<string>("Remarks")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("remarks");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<Guid?>("TransactionId")
.HasColumnType("uuid")
.HasColumnName("transaction_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_payment_orders");
b.HasIndex("PayeeWalletId")
.HasDatabaseName("ix_payment_orders_payee_wallet_id");
b.HasIndex("TransactionId")
.HasDatabaseName("ix_payment_orders_transaction_id");
b.ToTable("payment_orders", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Transaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("amount");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("currency");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Guid?>("PayeeWalletId")
.HasColumnType("uuid")
.HasColumnName("payee_wallet_id");
b.Property<Guid?>("PayerWalletId")
.HasColumnType("uuid")
.HasColumnName("payer_wallet_id");
b.Property<string>("Remarks")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("remarks");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_payment_transactions");
b.HasIndex("PayeeWalletId")
.HasDatabaseName("ix_payment_transactions_payee_wallet_id");
b.HasIndex("PayerWalletId")
.HasDatabaseName("ix_payment_transactions_payer_wallet_id");
b.ToTable("payment_transactions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Wallet", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallets");
b.HasIndex("AccountId")
.HasDatabaseName("ix_wallets_account_id");
b.ToTable("wallets", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.WalletPocket", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("amount");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("currency");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<Guid>("WalletId")
.HasColumnType("uuid")
.HasColumnName("wallet_id");
b.HasKey("Id")
.HasName("pk_wallet_pockets");
b.HasIndex("WalletId")
.HasDatabaseName("ix_wallet_pockets_wallet_id");
b.ToTable("wallet_pockets", (string)null);
});
modelBuilder.Entity("PostPostCategory", b =>
{
b.Property<Guid>("CategoriesId")
@ -2437,6 +2730,18 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Sender");
});
modelBuilder.Entity("DysonNetwork.Sphere.Developer.CustomApp", b =>
{
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Developer")
.WithMany()
.HasForeignKey("PublisherId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_custom_apps_publishers_publisher_id");
b.Navigation("Developer");
});
modelBuilder.Entity("DysonNetwork.Sphere.Permission.PermissionGroupMember", b =>
{
b.HasOne("DysonNetwork.Sphere.Permission.PermissionGroup", "Group")
@ -2467,7 +2772,7 @@ namespace DysonNetwork.Sphere.Migrations
.OnDelete(DeleteBehavior.Restrict)
.HasConstraintName("fk_posts_posts_forwarded_post_id");
b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher")
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher")
.WithMany("Posts")
.HasForeignKey("PublisherId")
.OnDelete(DeleteBehavior.Cascade)
@ -2496,7 +2801,7 @@ namespace DysonNetwork.Sphere.Migrations
modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCollection", b =>
{
b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher")
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher")
.WithMany("Collections")
.HasForeignKey("PublisherId")
.OnDelete(DeleteBehavior.Cascade)
@ -2527,7 +2832,7 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Post");
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b =>
modelBuilder.Entity("DysonNetwork.Sphere.Publisher.Publisher", b =>
{
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
.WithMany()
@ -2558,7 +2863,19 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Realm");
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.PublisherMember", b =>
modelBuilder.Entity("DysonNetwork.Sphere.Publisher.PublisherFeature", b =>
{
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher")
.WithMany()
.HasForeignKey("PublisherId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_publisher_features_publishers_publisher_id");
b.Navigation("Publisher");
});
modelBuilder.Entity("DysonNetwork.Sphere.Publisher.PublisherMember", b =>
{
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
.WithMany()
@ -2567,7 +2884,7 @@ namespace DysonNetwork.Sphere.Migrations
.IsRequired()
.HasConstraintName("fk_publisher_members_accounts_account_id");
b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher")
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher")
.WithMany("Members")
.HasForeignKey("PublisherId")
.OnDelete(DeleteBehavior.Cascade)
@ -2579,7 +2896,7 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Publisher");
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.PublisherSubscription", b =>
modelBuilder.Entity("DysonNetwork.Sphere.Publisher.PublisherSubscription", b =>
{
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
.WithMany()
@ -2588,7 +2905,7 @@ namespace DysonNetwork.Sphere.Migrations
.IsRequired()
.HasConstraintName("fk_publisher_subscriptions_accounts_account_id");
b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher")
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher")
.WithMany("Subscriptions")
.HasForeignKey("PublisherId")
.OnDelete(DeleteBehavior.Cascade)
@ -2670,7 +2987,7 @@ namespace DysonNetwork.Sphere.Migrations
modelBuilder.Entity("DysonNetwork.Sphere.Sticker.StickerPack", b =>
{
b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher")
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher")
.WithMany()
.HasForeignKey("PublisherId")
.OnDelete(DeleteBehavior.Cascade)
@ -2702,6 +3019,66 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Order", b =>
{
b.HasOne("DysonNetwork.Sphere.Wallet.Wallet", "PayeeWallet")
.WithMany()
.HasForeignKey("PayeeWalletId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_payment_orders_wallets_payee_wallet_id");
b.HasOne("DysonNetwork.Sphere.Wallet.Transaction", "Transaction")
.WithMany()
.HasForeignKey("TransactionId")
.HasConstraintName("fk_payment_orders_payment_transactions_transaction_id");
b.Navigation("PayeeWallet");
b.Navigation("Transaction");
});
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Transaction", b =>
{
b.HasOne("DysonNetwork.Sphere.Wallet.Wallet", "PayeeWallet")
.WithMany()
.HasForeignKey("PayeeWalletId")
.HasConstraintName("fk_payment_transactions_wallets_payee_wallet_id");
b.HasOne("DysonNetwork.Sphere.Wallet.Wallet", "PayerWallet")
.WithMany()
.HasForeignKey("PayerWalletId")
.HasConstraintName("fk_payment_transactions_wallets_payer_wallet_id");
b.Navigation("PayeeWallet");
b.Navigation("PayerWallet");
});
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Wallet", b =>
{
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_wallets_accounts_account_id");
b.Navigation("Account");
});
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.WalletPocket", b =>
{
b.HasOne("DysonNetwork.Sphere.Wallet.Wallet", "Wallet")
.WithMany("Pockets")
.HasForeignKey("WalletId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_wallet_pockets_wallets_wallet_id");
b.Navigation("Wallet");
});
modelBuilder.Entity("PostPostCategory", b =>
{
b.HasOne("DysonNetwork.Sphere.Post.PostCategory", null)
@ -2801,7 +3178,7 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Reactions");
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b =>
modelBuilder.Entity("DysonNetwork.Sphere.Publisher.Publisher", b =>
{
b.Navigation("Collections");
@ -2818,6 +3195,11 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Members");
});
modelBuilder.Entity("DysonNetwork.Sphere.Wallet.Wallet", b =>
{
b.Navigation("Pockets");
});
#pragma warning restore 612, 618
}
}

View File

@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Storage;
using NodaTime;
@ -55,7 +54,7 @@ public class Post : ModelBase
[JsonIgnore] public NpgsqlTsVector SearchVector { get; set; } = null!;
public Publisher Publisher { get; set; } = null!;
public Publisher.Publisher Publisher { get; set; } = null!;
public ICollection<PostReaction> Reactions { get; set; } = new List<PostReaction>();
public ICollection<PostTag> Tags { get; set; } = new List<PostTag>();
public ICollection<PostCategory> Categories { get; set; } = new List<PostCategory>();
@ -88,7 +87,7 @@ public class PostCollection : ModelBase
[MaxLength(256)] public string? Name { get; set; }
[MaxLength(4096)] public string? Description { get; set; }
public Publisher Publisher { get; set; } = null!;
public Publisher.Publisher Publisher { get; set; } = null!;
public ICollection<Post> Posts { get; set; } = new List<Post>();
}

View File

@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Publisher;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -11,7 +12,13 @@ namespace DysonNetwork.Sphere.Post;
[ApiController]
[Route("/posts")]
public class PostController(AppDatabase db, PostService ps, RelationshipService rels, IServiceScopeFactory factory)
public class PostController(
AppDatabase db,
PostService ps,
PublisherService pub,
RelationshipService rels,
IServiceScopeFactory factory
)
: ControllerBase
{
[HttpGet]
@ -22,8 +29,7 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
var currentUser = currentUserValue as Account.Account;
var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
var publisher = pubName == null ? null :
await db.Publishers.FirstOrDefaultAsync(p => p.Name == pubName);
var publisher = pubName == null ? null : await db.Publishers.FirstOrDefaultAsync(p => p.Name == pubName);
var query = db.Posts.AsQueryable();
if (publisher != null)
@ -150,12 +156,12 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
Publisher? publisher;
Publisher.Publisher? publisher;
if (publisherName is null)
{
// Use the first personal publisher
publisher = await db.Publishers.FirstOrDefaultAsync(e =>
e.AccountId == currentUser.Id && e.PublisherType == PublisherType.Individual);
e.AccountId == currentUser.Id && e.Type == PublisherType.Individual);
}
else
{
@ -281,10 +287,7 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
.FirstOrDefaultAsync();
if (post is null) return NotFound();
var member = await db.PublisherMembers
.FirstOrDefaultAsync(e => e.AccountId == currentUser.Id && e.PublisherId == post.Publisher.Id);
if (member is null) return StatusCode(403, "You even wasn't a member of the publisher you specified.");
if (member.Role < PublisherMemberRole.Editor)
if (!await pub.IsMemberWithRole(post.Publisher.Id, currentUser.Id, PublisherMemberRole.Editor))
return StatusCode(403, "You need at least be an editor to edit this publisher's post.");
if (request.Title is not null) post.Title = request.Title;
@ -324,14 +327,10 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
.FirstOrDefaultAsync();
if (post is null) return NotFound();
var member = await db.PublisherMembers
.FirstOrDefaultAsync(e => e.AccountId == currentUser.Id && e.PublisherId == post.Publisher.Id);
if (member is null) return StatusCode(403, "You even wasn't a member of the publisher you specified.");
if (member.Role < PublisherMemberRole.Editor)
if (!await pub.IsMemberWithRole(post.Publisher.Id, currentUser.Id, PublisherMemberRole.Editor))
return StatusCode(403, "You need at least be an editor to delete the publisher's post.");
await ps.DeletePostAsync(post);
return NoContent();
}
}

View File

@ -14,9 +14,11 @@ using DysonNetwork.Sphere.Connection.Handlers;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.Sticker;
using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
@ -165,6 +167,8 @@ builder.Services.AddScoped<RealmService>();
builder.Services.AddScoped<ChatRoomService>();
builder.Services.AddScoped<ChatService>();
builder.Services.AddScoped<StickerService>();
builder.Services.AddScoped<WalletService>();
builder.Services.AddScoped<PaymentService>();
// Timed task

View File

@ -1,11 +1,11 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Post;
namespace DysonNetwork.Sphere.Publisher;
public enum PublisherType
{
@ -17,7 +17,7 @@ public enum PublisherType
public class Publisher : ModelBase
{
public Guid Id { get; set; }
public PublisherType PublisherType { get; set; }
public PublisherType Type { get; set; }
[MaxLength(256)] public string Name { get; set; } = string.Empty;
[MaxLength(256)] public string Nick { get; set; } = string.Empty;
[MaxLength(4096)] public string? Bio { get; set; }
@ -27,7 +27,7 @@ public class Publisher : ModelBase
public string? BackgroundId { get; set; }
public CloudFile? Background { get; set; }
[JsonIgnore] public ICollection<Post> Posts { get; set; } = new List<Post>();
[JsonIgnore] public ICollection<Post.Post> Posts { get; set; } = new List<Post.Post>();
[JsonIgnore] public ICollection<PostCollection> Collections { get; set; } = new List<PostCollection>();
[JsonIgnore] public ICollection<PublisherMember> Members { get; set; } = new List<PublisherMember>();
@ -78,3 +78,13 @@ public class PublisherSubscription : ModelBase
public SubscriptionStatus Status { get; set; } = SubscriptionStatus.Active;
public int Tier { get; set; } = 0;
}
public class PublisherFeature : ModelBase
{
public Guid Id { get; set; }
[MaxLength(1024)] public string Flag { get; set; } = null!;
public Instant? ExpiredAt { get; set; }
public Guid PublisherId { get; set; }
public Publisher Publisher { get; set; } = null!;
}

View File

@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Realm;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
@ -7,7 +8,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Post;
namespace DysonNetwork.Sphere.Publisher;
[ApiController]
[Route("/publishers")]
@ -15,7 +16,7 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
: ControllerBase
{
[HttpGet("{name}")]
public async Task<ActionResult<Publisher>> GetPublisher(string name)
public async Task<ActionResult<Sphere.Publisher.Publisher>> GetPublisher(string name)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
@ -37,7 +38,7 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
[HttpGet]
[Authorize]
public async Task<ActionResult<List<Publisher>>> ListManagedPublishers()
public async Task<ActionResult<List<Sphere.Publisher.Publisher>>> ListManagedPublishers()
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
@ -121,7 +122,7 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
[HttpPost("invites/{name}/accept")]
[Authorize]
public async Task<ActionResult<Publisher>> AcceptMemberInvite(string name)
public async Task<ActionResult<Sphere.Publisher.Publisher>> AcceptMemberInvite(string name)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
@ -173,7 +174,7 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
[HttpPost("individual")]
[Authorize]
[RequiredPermission("global", "publishers.create")]
public async Task<ActionResult<Publisher>> CreatePublisherIndividual([FromBody] PublisherRequest request)
public async Task<ActionResult<Sphere.Publisher.Publisher>> CreatePublisherIndividual([FromBody] PublisherRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
@ -217,7 +218,7 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
[HttpPost("organization/{realmSlug}")]
[Authorize]
[RequiredPermission("global", "publishers.create")]
public async Task<ActionResult<Publisher>> CreatePublisherOrganization(string realmSlug, [FromBody] PublisherRequest request)
public async Task<ActionResult<Sphere.Publisher.Publisher>> CreatePublisherOrganization(string realmSlug, [FromBody] PublisherRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
@ -265,7 +266,7 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
[HttpPatch("{name}")]
[Authorize]
public async Task<ActionResult<Publisher>> UpdatePublisher(string name, PublisherRequest request)
public async Task<ActionResult<Sphere.Publisher.Publisher>> UpdatePublisher(string name, PublisherRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;
@ -316,7 +317,7 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
[HttpDelete("{name}")]
[Authorize]
public async Task<ActionResult<Publisher>> DeletePublisher(string name)
public async Task<ActionResult<Sphere.Publisher.Publisher>> DeletePublisher(string name)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var userId = currentUser.Id;

View File

@ -1,9 +1,10 @@
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using NodaTime;
namespace DysonNetwork.Sphere.Post;
namespace DysonNetwork.Sphere.Publisher;
public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache)
{
@ -18,7 +19,7 @@ public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache
{
var publisher = new Publisher
{
PublisherType = PublisherType.Individual,
Type = PublisherType.Individual,
Name = name ?? account.Name,
Nick = nick ?? account.Nick,
Bio = bio ?? account.Profile.Bio,
@ -57,7 +58,7 @@ public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache
{
var publisher = new Publisher
{
PublisherType = PublisherType.Organizational,
Type = PublisherType.Organizational,
Name = name ?? realm.Slug,
Nick = nick ?? realm.Name,
Bio = bio ?? realm.Description,
@ -95,6 +96,7 @@ public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache
}
private const string PublisherStatsCacheKey = "PublisherStats_{0}";
private const string PublisherFeatureCacheKey = "PublisherFeature_{0}_{1}";
public async Task<PublisherStats?> GetPublisherStats(string name)
{
@ -134,4 +136,56 @@ public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache
cache.Set(cacheKey, stats, TimeSpan.FromMinutes(5));
return stats;
}
public async Task SetFeatureFlag(Guid publisherId, string flag)
{
var featureFlag = await db.PublisherFeatures
.FirstOrDefaultAsync(f => f.PublisherId == publisherId && f.Flag == flag);
if (featureFlag == null)
{
featureFlag = new PublisherFeature
{
PublisherId = publisherId,
Flag = flag,
};
db.PublisherFeatures.Add(featureFlag);
}
else
{
featureFlag.ExpiredAt = SystemClock.Instance.GetCurrentInstant();
}
await db.SaveChangesAsync();
cache.Remove(string.Format(PublisherFeatureCacheKey, publisherId, flag));
}
public async Task<bool> HasFeature(Guid publisherId, string flag)
{
var cacheKey = string.Format(PublisherFeatureCacheKey, publisherId, flag);
if (cache.TryGetValue(cacheKey, out bool isEnabled))
return isEnabled;
var now = SystemClock.Instance.GetCurrentInstant();
var featureFlag = await db.PublisherFeatures
.FirstOrDefaultAsync(f =>
f.PublisherId == publisherId && f.Flag == flag &&
(f.ExpiredAt == null || f.ExpiredAt > now)
);
if (featureFlag is not null) isEnabled = true;
cache.Set(cacheKey, isEnabled, TimeSpan.FromMinutes(5));
return isEnabled;
}
public async Task<bool> IsMemberWithRole(Guid publisherId, Guid accountId, PublisherMemberRole requiredRole)
{
var member = await db.Publishers
.Where(p => p.Id == publisherId)
.SelectMany(p => p.Members)
.FirstOrDefaultAsync(m => m.AccountId == accountId);
return member != null && member.Role >= requiredRole;
}
}

View File

@ -1,8 +1,9 @@
using DysonNetwork.Sphere.Post;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Post;
namespace DysonNetwork.Sphere.Publisher;
[ApiController]
[Route("/publishers")]

View File

@ -1,8 +1,7 @@
using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Post;
namespace DysonNetwork.Sphere.Publisher;
public class PublisherSubscriptionService(AppDatabase db, NotificationService nty)
{
@ -38,7 +37,7 @@ public class PublisherSubscriptionService(AppDatabase db, NotificationService nt
/// </summary>
/// <param name="post">The new post</param>
/// <returns>The number of subscribers notified</returns>
public async Task<int> NotifySubscribersPostAsync(Post post)
public async Task<int> NotifySubscribersPostAsync(Post.Post post)
{
var subscribers = await db.PublisherSubscriptions
.Include(ps => ps.Account)

View File

@ -7,7 +7,7 @@ namespace DysonNetwork.Sphere.Realm;
[ApiController]
[Route("/realm/{slug}")]
public class RealmChatController(AppDatabase db) : ControllerBase
public class RealmChatController(AppDatabase db, RealmService rs) : ControllerBase
{
[HttpGet("chat")]
[Authorize]
@ -22,11 +22,8 @@ public class RealmChatController(AppDatabase db) : ControllerBase
if (!realm.IsPublic)
{
if (currentUser is null) return Unauthorized();
var member = await db.ChatMembers
.Where(m => m.ChatRoomId == realm.Id)
.Where(m => m.AccountId == currentUser.Id)
.FirstOrDefaultAsync();
if (member is null) return BadRequest("You need at least one member to view the realm's chat.");
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Normal))
return StatusCode(403, "You need at least one member to view the realm's chat.");
}
var chatRooms = await db.ChatRooms

View File

@ -78,15 +78,7 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
.FirstOrDefaultAsync();
if (realm is null) return NotFound();
var member = await db.RealmMembers
.Where(m => m.AccountId == userId)
.Where(m => m.RealmId == realm.Id)
.FirstOrDefaultAsync();
if (member is null) return StatusCode(403, "You are not even a member of the targeted realm.");
if (member.Role < RealmMemberRole.Moderator)
return StatusCode(403,
"You need at least be a manager to invite other members to collaborate this realm.");
if (member.Role < request.Role)
if (!await rs.IsMemberWithRole(realm.Id, userId, request.Role))
return StatusCode(403, "You cannot invite member has higher permission than yours.");
var newMember = new RealmMember
@ -162,9 +154,8 @@ public class RealmController(AppDatabase db, RealmService rs, FileService fs) :
if (!realm.IsPublic)
{
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
var isMember = await db.RealmMembers
.AnyAsync(m => m.AccountId == currentUser.Id && m.RealmId == realm.Id && m.JoinedAt != null);
if (!isMember) return StatusCode(403, "You must be a member to view this realm's members.");
if (!await rs.IsMemberWithRole(realm.Id, currentUser.Id, RealmMemberRole.Normal))
return StatusCode(403, "You must be a member to view this realm's members.");
}
var query = db.RealmMembers

View File

@ -1,4 +1,5 @@
using DysonNetwork.Sphere.Account;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Realm;
@ -9,4 +10,11 @@ public class RealmService(AppDatabase db, NotificationService nty)
await nty.SendNotification(member.Account, "invites.realms", "New Realm Invitation", null,
$"You just got invited to join {member.Realm.Name}");
}
public async Task<bool> IsMemberWithRole(Guid realmId, Guid accountId, RealmMemberRole requiredRole)
{
var member = await db.RealmMembers
.FirstOrDefaultAsync(m => m.RealmId == realmId && m.AccountId == accountId);
return member?.Role >= requiredRole;
}
}

View File

@ -23,6 +23,6 @@ public class StickerPack : ModelBase
[MaxLength(128)] public string Prefix { get; set; } = null!;
public Guid PublisherId { get; set; }
public Post.Publisher Publisher { get; set; } = null!;
public Publisher.Publisher Publisher { get; set; } = null!;
}

View File

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

View File

@ -0,0 +1,51 @@
using System.ComponentModel.DataAnnotations;
using NodaTime;
namespace DysonNetwork.Sphere.Wallet;
public enum OrderStatus
{
Unpaid,
Paid,
Cancelled,
Finished,
Expired
}
public class Order : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public OrderStatus Status { get; set; } = OrderStatus.Unpaid;
[MaxLength(128)] public string Currency { get; set; } = null!;
[MaxLength(4096)] public string? Remarks { get; set; }
public decimal Amount { get; set; }
public Instant ExpiredAt { get; set; }
public Guid PayeeWalletId { get; set; }
public Wallet PayeeWallet { get; set; } = null!;
public Guid? TransactionId { get; set; }
public Transaction? Transaction { get; set; }
}
public enum TransactionType
{
System,
Transfer,
Order
}
public class Transaction : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(128)] public string Currency { get; set; } = null!;
public decimal Amount { get; set; }
[MaxLength(4096)] public string? Remarks { get; set; }
public TransactionType Type { get; set; }
// When the payer is null, it's pay from the system
public Guid? PayerWalletId { get; set; }
public Wallet? PayerWallet { get; set; }
// When the payee is null, it's pay for the system
public Guid? PayeeWalletId { get; set; }
public Wallet? PayeeWallet { get; set; }
}

View File

@ -0,0 +1,184 @@
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Wallet;
public class PaymentService(AppDatabase db, WalletService wat)
{
public async Task<Order> CreateOrderAsync(Guid payeeWalletId, string currency, decimal amount, Duration expiration)
{
var order = new Order
{
PayeeWalletId = payeeWalletId,
Currency = currency,
Amount = amount,
ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration)
};
db.PaymentOrders.Add(order);
await db.SaveChangesAsync();
return order;
}
public async Task<Transaction> CreateTransactionAsync(
Guid? payerWalletId,
Guid? payeeWalletId,
string currency,
decimal amount,
string? remarks = null,
TransactionType type = TransactionType.System
)
{
var transaction = new Transaction
{
PayerWalletId = payerWalletId,
PayeeWalletId = payeeWalletId,
Currency = currency,
Amount = amount,
Remarks = remarks,
Type = type
};
if (payerWalletId.HasValue)
{
var payerPocket = await wat.GetOrCreateWalletPocketAsync(
(await db.Wallets.FindAsync(payerWalletId.Value))!.AccountId,
currency);
if (payerPocket.Amount < amount)
{
throw new InvalidOperationException("Insufficient funds");
}
payerPocket.Amount -= amount;
}
if (payeeWalletId.HasValue)
{
var payeeWallet = await db.Wallets.FindAsync(payeeWalletId.Value);
var payeePocket = await wat.GetOrCreateWalletPocketAsync(
payeeWallet!.AccountId,
currency);
payeePocket.Amount += amount;
}
db.PaymentTransactions.Add(transaction);
await db.SaveChangesAsync();
return transaction;
}
public async Task<Order> PayOrderAsync(Guid orderId, Guid payerWalletId)
{
var order = await db.PaymentOrders
.Include(o => o.Transaction)
.FirstOrDefaultAsync(o => o.Id == orderId);
if (order == null)
{
throw new InvalidOperationException("Order not found");
}
if (order.Status != OrderStatus.Unpaid)
{
throw new InvalidOperationException($"Order is in invalid status: {order.Status}");
}
if (order.ExpiredAt < SystemClock.Instance.GetCurrentInstant())
{
order.Status = OrderStatus.Expired;
await db.SaveChangesAsync();
throw new InvalidOperationException("Order has expired");
}
var transaction = await CreateTransactionAsync(
payerWalletId,
order.PayeeWalletId,
order.Currency,
order.Amount,
order.Remarks ?? $"Payment for Order #{order.Id}",
type: TransactionType.Order);
order.TransactionId = transaction.Id;
order.Transaction = transaction;
order.Status = OrderStatus.Paid;
await db.SaveChangesAsync();
return order;
}
public async Task<Order> CancelOrderAsync(Guid orderId)
{
var order = await db.PaymentOrders.FindAsync(orderId);
if (order == null)
{
throw new InvalidOperationException("Order not found");
}
if (order.Status != OrderStatus.Unpaid)
{
throw new InvalidOperationException($"Cannot cancel order in status: {order.Status}");
}
order.Status = OrderStatus.Cancelled;
await db.SaveChangesAsync();
return order;
}
public async Task<(Order Order, Transaction RefundTransaction)> RefundOrderAsync(Guid orderId)
{
var order = await db.PaymentOrders
.Include(o => o.Transaction)
.FirstOrDefaultAsync(o => o.Id == orderId);
if (order == null)
{
throw new InvalidOperationException("Order not found");
}
if (order.Status != OrderStatus.Paid)
{
throw new InvalidOperationException($"Cannot refund order in status: {order.Status}");
}
if (order.Transaction == null)
{
throw new InvalidOperationException("Order has no associated transaction");
}
var refundTransaction = await CreateTransactionAsync(
order.PayeeWalletId,
order.Transaction.PayerWalletId,
order.Currency,
order.Amount,
$"Refund for order {order.Id}");
order.Status = OrderStatus.Finished;
await db.SaveChangesAsync();
return (order, refundTransaction);
}
public async Task<Transaction> TransferAsync(Guid payerAccountId, Guid payeeAccountId, string currency, decimal amount)
{
var payerWallet = await wat.GetWalletAsync(payerAccountId);
if (payerWallet == null)
{
throw new InvalidOperationException($"Payer wallet not found for account {payerAccountId}");
}
var payeeWallet = await wat.GetWalletAsync(payeeAccountId);
if (payeeWallet == null)
{
throw new InvalidOperationException($"Payee wallet not found for account {payeeAccountId}");
}
return await CreateTransactionAsync(
payerWallet.Id,
payeeWallet.Id,
currency,
amount,
$"Transfer from account {payerAccountId} to {payeeAccountId}",
TransactionType.Transfer);
}
}

View File

@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace DysonNetwork.Sphere.Wallet;
public class Wallet : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public ICollection<WalletPocket> Pockets { get; set; } = new List<WalletPocket>();
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
}
public class WalletPocket : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(128)] public string Currency { get; set; } = null!;
public decimal Amount { get; set; }
public Guid WalletId { get; set; }
public Wallet Wallet { get; set; } = null!;
}

View File

@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Sphere.Wallet;
public class WalletService(AppDatabase db)
{
public async Task<Wallet?> GetWalletAsync(Guid accountId)
{
return await db.Wallets
.Include(w => w.Pockets)
.FirstOrDefaultAsync(w => w.AccountId == accountId);
}
public async Task<Wallet> CreateWalletAsync(Guid accountId)
{
var wallet = new Wallet
{
AccountId = accountId
};
db.Wallets.Add(wallet);
await db.SaveChangesAsync();
return wallet;
}
public async Task<WalletPocket> GetOrCreateWalletPocketAsync(Guid accountId, string currency)
{
var wallet = await db.Wallets
.Include(w => w.Pockets)
.FirstOrDefaultAsync(w => w.AccountId == accountId);
if (wallet == null)
{
throw new InvalidOperationException($"Wallet not found for account {accountId}");
}
var pocket = wallet.Pockets.FirstOrDefault(p => p.Currency == currency);
if (pocket != null) return pocket;
pocket = new WalletPocket
{
Currency = currency,
Amount = 0,
WalletId = wallet.Id
};
wallet.Pockets.Add(pocket);
await db.SaveChangesAsync();
return pocket;
}
}