✨ Wallet, payment, developer apps, feature flags of publishers
♻️ Simplified the permission check of chat room, realm, publishers
This commit is contained in:
@ -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>();
|
||||
}
|
||||
|
@ -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]
|
||||
@ -21,14 +28,13 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
|
||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||
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)
|
||||
query = query.Where(p => p.Publisher.Id == publisher.Id);
|
||||
|
||||
|
||||
var totalCount = await query
|
||||
.FilterWithVisibility(currentUser, userFriends, isListing: true)
|
||||
.CountAsync();
|
||||
@ -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();
|
||||
}
|
||||
}
|
@ -1,80 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Post;
|
||||
|
||||
public enum PublisherType
|
||||
{
|
||||
Individual,
|
||||
Organizational
|
||||
}
|
||||
|
||||
[Index(nameof(Name), IsUnique = true)]
|
||||
public class Publisher : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public PublisherType PublisherType { 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; }
|
||||
|
||||
public string? PictureId { get; set; }
|
||||
public CloudFile? Picture { get; set; }
|
||||
public string? BackgroundId { get; set; }
|
||||
public CloudFile? Background { get; set; }
|
||||
|
||||
[JsonIgnore] public ICollection<Post> Posts { get; set; } = new List<Post>();
|
||||
[JsonIgnore] public ICollection<PostCollection> Collections { get; set; } = new List<PostCollection>();
|
||||
[JsonIgnore] public ICollection<PublisherMember> Members { get; set; } = new List<PublisherMember>();
|
||||
|
||||
[JsonIgnore]
|
||||
public ICollection<PublisherSubscription> Subscriptions { get; set; } = new List<PublisherSubscription>();
|
||||
|
||||
public Guid? AccountId { get; set; }
|
||||
[JsonIgnore] public Account.Account? Account { get; set; }
|
||||
public Guid? RealmId { get; set; }
|
||||
[JsonIgnore] public Realm.Realm? Realm { get; set; }
|
||||
}
|
||||
|
||||
public enum PublisherMemberRole
|
||||
{
|
||||
Owner = 100,
|
||||
Manager = 75,
|
||||
Editor = 50,
|
||||
Viewer = 25
|
||||
}
|
||||
|
||||
public class PublisherMember : ModelBase
|
||||
{
|
||||
public Guid PublisherId { get; set; }
|
||||
[JsonIgnore] public Publisher Publisher { get; set; } = null!;
|
||||
public Guid AccountId { get; set; }
|
||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||
|
||||
public PublisherMemberRole Role { get; set; } = PublisherMemberRole.Viewer;
|
||||
public Instant? JoinedAt { get; set; }
|
||||
}
|
||||
|
||||
public enum SubscriptionStatus
|
||||
{
|
||||
Active,
|
||||
Expired,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
public class PublisherSubscription : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid PublisherId { get; set; }
|
||||
[JsonIgnore] public Publisher Publisher { get; set; } = null!;
|
||||
public Guid AccountId { get; set; }
|
||||
[JsonIgnore] public Account.Account Account { get; set; } = null!;
|
||||
|
||||
public SubscriptionStatus Status { get; set; } = SubscriptionStatus.Active;
|
||||
public int Tier { get; set; } = 0;
|
||||
}
|
@ -1,349 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Sphere.Permission;
|
||||
using DysonNetwork.Sphere.Realm;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Post;
|
||||
|
||||
[ApiController]
|
||||
[Route("/publishers")]
|
||||
public class PublisherController(AppDatabase db, PublisherService ps, FileService fs)
|
||||
: ControllerBase
|
||||
{
|
||||
[HttpGet("{name}")]
|
||||
public async Task<ActionResult<Publisher>> GetPublisher(string name)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var publisher = await db.Publishers
|
||||
.Where(e => e.Name == name)
|
||||
.FirstOrDefaultAsync();
|
||||
if (publisher is null) return NotFound();
|
||||
|
||||
return Ok(publisher);
|
||||
}
|
||||
|
||||
[HttpGet("{name}/stats")]
|
||||
public async Task<ActionResult<PublisherService.PublisherStats>> GetPublisherStats(string name)
|
||||
{
|
||||
var stats = await ps.GetPublisherStats(name);
|
||||
if (stats is null) return NotFound();
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<Publisher>>> ListManagedPublishers()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
|
||||
var members = await db.PublisherMembers
|
||||
.Where(m => m.AccountId == userId)
|
||||
.Where(m => m.JoinedAt != null)
|
||||
.Include(e => e.Publisher)
|
||||
.Include(e => e.Publisher.Picture)
|
||||
.Include(e => e.Publisher.Background)
|
||||
.ToListAsync();
|
||||
|
||||
return members.Select(m => m.Publisher).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("invites")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<PublisherMember>>> ListInvites()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
|
||||
var members = await db.PublisherMembers
|
||||
.Where(m => m.AccountId == userId)
|
||||
.Where(m => m.JoinedAt == null)
|
||||
.Include(e => e.Publisher)
|
||||
.Include(e => e.Publisher.Picture)
|
||||
.Include(e => e.Publisher.Background)
|
||||
.ToListAsync();
|
||||
|
||||
return members.ToList();
|
||||
}
|
||||
|
||||
public class PublisherMemberRequest
|
||||
{
|
||||
[Required] public long RelatedUserId { get; set; }
|
||||
[Required] public PublisherMemberRole Role { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("invites/{name}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<PublisherMember>> InviteMember(string name,
|
||||
[FromBody] PublisherMemberRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
|
||||
var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId);
|
||||
if (relatedUser is null) return BadRequest("Related user was not found");
|
||||
|
||||
var publisher = await db.Publishers
|
||||
.Where(p => p.Name == name)
|
||||
.Include(publisher => publisher.Picture)
|
||||
.Include(publisher => publisher.Background)
|
||||
.FirstOrDefaultAsync();
|
||||
if (publisher is null) return NotFound();
|
||||
|
||||
var member = await db.PublisherMembers
|
||||
.Where(m => m.AccountId == userId)
|
||||
.Where(m => m.PublisherId == publisher.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member is null) return StatusCode(403, "You are not even a member of the targeted publisher.");
|
||||
if (member.Role < PublisherMemberRole.Manager)
|
||||
return StatusCode(403,
|
||||
"You need at least be a manager to invite other members to collaborate this publisher.");
|
||||
if (member.Role < request.Role)
|
||||
return StatusCode(403, "You cannot invite member has higher permission than yours.");
|
||||
|
||||
var newMember = new PublisherMember
|
||||
{
|
||||
AccountId = relatedUser.Id,
|
||||
PublisherId = publisher.Id,
|
||||
Role = request.Role,
|
||||
};
|
||||
|
||||
db.PublisherMembers.Add(newMember);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(newMember);
|
||||
}
|
||||
|
||||
[HttpPost("invites/{name}/accept")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Publisher>> AcceptMemberInvite(string name)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
|
||||
var member = await db.PublisherMembers
|
||||
.Where(m => m.AccountId == userId)
|
||||
.Where(m => m.Publisher.Name == name)
|
||||
.Where(m => m.JoinedAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member is null) return NotFound();
|
||||
|
||||
member.JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
||||
db.Update(member);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(member);
|
||||
}
|
||||
|
||||
[HttpPost("invites/{name}/decline")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> DeclineMemberInvite(string name)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
|
||||
var member = await db.PublisherMembers
|
||||
.Where(m => m.AccountId == userId)
|
||||
.Where(m => m.Publisher.Name == name)
|
||||
.Where(m => m.JoinedAt == null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member is null) return NotFound();
|
||||
|
||||
db.PublisherMembers.Remove(member);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
public class PublisherRequest
|
||||
{
|
||||
[MaxLength(256)] public string? Name { get; set; }
|
||||
[MaxLength(256)] public string? Nick { get; set; }
|
||||
[MaxLength(4096)] public string? Bio { get; set; }
|
||||
|
||||
public string? PictureId { get; set; }
|
||||
public string? BackgroundId { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost("individual")]
|
||||
[Authorize]
|
||||
[RequiredPermission("global", "publishers.create")]
|
||||
public async Task<ActionResult<Publisher>> CreatePublisherIndividual([FromBody] PublisherRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var takenName = request.Name ?? currentUser.Name;
|
||||
var duplicateNameCount = await db.Publishers
|
||||
.Where(p => p.Name == takenName)
|
||||
.CountAsync();
|
||||
if (duplicateNameCount > 0)
|
||||
return BadRequest(
|
||||
"The name you requested has already be taken, " +
|
||||
"if it is your account name, " +
|
||||
"you can request a taken down to the publisher which created with " +
|
||||
"your name firstly to get your name back."
|
||||
);
|
||||
|
||||
CloudFile? picture = null, background = null;
|
||||
if (request.PictureId is not null)
|
||||
{
|
||||
picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
|
||||
if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
|
||||
}
|
||||
|
||||
if (request.BackgroundId is not null)
|
||||
{
|
||||
background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
|
||||
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
|
||||
}
|
||||
|
||||
var publisher = await ps.CreateIndividualPublisher(
|
||||
currentUser,
|
||||
request.Name,
|
||||
request.Nick,
|
||||
request.Bio,
|
||||
picture,
|
||||
background
|
||||
);
|
||||
|
||||
return Ok(publisher);
|
||||
}
|
||||
|
||||
[HttpPost("organization/{realmSlug}")]
|
||||
[Authorize]
|
||||
[RequiredPermission("global", "publishers.create")]
|
||||
public async Task<ActionResult<Publisher>> CreatePublisherOrganization(string realmSlug, [FromBody] PublisherRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var realm = await db.Realms.FirstOrDefaultAsync(r => r.Slug == realmSlug);
|
||||
if (realm == null) return NotFound("Realm not found");
|
||||
|
||||
var isAdmin = await db.RealmMembers
|
||||
.AnyAsync(m => m.RealmId == realm.Id && m.AccountId == currentUser.Id && m.Role >= RealmMemberRole.Moderator);
|
||||
if (!isAdmin) return StatusCode(403, "You need to be a moderator of the realm to create an organization publisher");
|
||||
|
||||
var takenName = request.Name ?? realm.Slug;
|
||||
var duplicateNameCount = await db.Publishers
|
||||
.Where(p => p.Name == takenName)
|
||||
.CountAsync();
|
||||
if (duplicateNameCount > 0)
|
||||
return BadRequest("The name you requested has already been taken");
|
||||
|
||||
CloudFile? picture = null, background = null;
|
||||
if (request.PictureId is not null)
|
||||
{
|
||||
picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
|
||||
if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud.");
|
||||
}
|
||||
|
||||
if (request.BackgroundId is not null)
|
||||
{
|
||||
background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
|
||||
if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud.");
|
||||
}
|
||||
|
||||
var publisher = await ps.CreateOrganizationPublisher(
|
||||
realm,
|
||||
currentUser,
|
||||
request.Name,
|
||||
request.Nick,
|
||||
request.Bio,
|
||||
picture,
|
||||
background
|
||||
);
|
||||
|
||||
return Ok(publisher);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[HttpPatch("{name}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Publisher>> UpdatePublisher(string name, PublisherRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
|
||||
var publisher = await db.Publishers
|
||||
.Where(p => p.Name == name)
|
||||
.Include(publisher => publisher.Picture)
|
||||
.Include(publisher => publisher.Background)
|
||||
.FirstOrDefaultAsync();
|
||||
if (publisher is null) return NotFound();
|
||||
|
||||
var member = await db.PublisherMembers
|
||||
.Where(m => m.AccountId == userId)
|
||||
.Where(m => m.PublisherId == publisher.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member is null) return StatusCode(403, "You are not even a member of the targeted publisher.");
|
||||
if (member.Role < PublisherMemberRole.Manager)
|
||||
return StatusCode(403, "You need at least be the manager to update the publisher profile.");
|
||||
|
||||
if (request.Name is not null) publisher.Name = request.Name;
|
||||
if (request.Nick is not null) publisher.Nick = request.Nick;
|
||||
if (request.Bio is not null) publisher.Bio = request.Bio;
|
||||
if (request.PictureId is not null)
|
||||
{
|
||||
var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync();
|
||||
if (picture is null) return BadRequest("Invalid picture id.");
|
||||
if (publisher.Picture is not null) await fs.MarkUsageAsync(publisher.Picture, -1);
|
||||
|
||||
publisher.Picture = picture;
|
||||
await fs.MarkUsageAsync(picture, 1);
|
||||
}
|
||||
|
||||
if (request.BackgroundId is not null)
|
||||
{
|
||||
var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync();
|
||||
if (background is null) return BadRequest("Invalid background id.");
|
||||
if (publisher.Background is not null) await fs.MarkUsageAsync(publisher.Background, -1);
|
||||
|
||||
publisher.Background = background;
|
||||
await fs.MarkUsageAsync(background, 1);
|
||||
}
|
||||
|
||||
db.Update(publisher);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(publisher);
|
||||
}
|
||||
|
||||
[HttpDelete("{name}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Publisher>> DeletePublisher(string name)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
var userId = currentUser.Id;
|
||||
|
||||
var publisher = await db.Publishers
|
||||
.Where(p => p.Name == name)
|
||||
.Include(publisher => publisher.Picture)
|
||||
.Include(publisher => publisher.Background)
|
||||
.FirstOrDefaultAsync();
|
||||
if (publisher is null) return NotFound();
|
||||
|
||||
var member = await db.PublisherMembers
|
||||
.Where(m => m.AccountId == userId)
|
||||
.Where(m => m.PublisherId == publisher.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (member is null) return StatusCode(403, "You are not even a member of the targeted publisher.");
|
||||
if (member.Role < PublisherMemberRole.Owner)
|
||||
return StatusCode(403, "You need to be the owner to delete the publisher.");
|
||||
|
||||
if (publisher.Picture is not null)
|
||||
await fs.MarkUsageAsync(publisher.Picture, -1);
|
||||
if (publisher.Background is not null)
|
||||
await fs.MarkUsageAsync(publisher.Background, -1);
|
||||
|
||||
db.Publishers.Remove(publisher);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
}
|
@ -1,137 +0,0 @@
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Post;
|
||||
|
||||
public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache)
|
||||
{
|
||||
public async Task<Publisher> CreateIndividualPublisher(
|
||||
Account.Account account,
|
||||
string? name,
|
||||
string? nick,
|
||||
string? bio,
|
||||
CloudFile? picture,
|
||||
CloudFile? background
|
||||
)
|
||||
{
|
||||
var publisher = new Publisher
|
||||
{
|
||||
PublisherType = PublisherType.Individual,
|
||||
Name = name ?? account.Name,
|
||||
Nick = nick ?? account.Nick,
|
||||
Bio = bio ?? account.Profile.Bio,
|
||||
Picture = picture ?? account.Profile.Picture,
|
||||
Background = background ?? account.Profile.Background,
|
||||
AccountId = account.Id,
|
||||
Members = new List<PublisherMember>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AccountId = account.Id,
|
||||
Role = PublisherMemberRole.Owner,
|
||||
JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
db.Publishers.Add(publisher);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (publisher.Picture is not null) await fs.MarkUsageAsync(publisher.Picture, 1);
|
||||
if (publisher.Background is not null) await fs.MarkUsageAsync(publisher.Background, 1);
|
||||
|
||||
return publisher;
|
||||
}
|
||||
|
||||
public async Task<Publisher> CreateOrganizationPublisher(
|
||||
Realm.Realm realm,
|
||||
Account.Account account,
|
||||
string? name,
|
||||
string? nick,
|
||||
string? bio,
|
||||
CloudFile? picture,
|
||||
CloudFile? background
|
||||
)
|
||||
{
|
||||
var publisher = new Publisher
|
||||
{
|
||||
PublisherType = PublisherType.Organizational,
|
||||
Name = name ?? realm.Slug,
|
||||
Nick = nick ?? realm.Name,
|
||||
Bio = bio ?? realm.Description,
|
||||
Picture = picture ?? realm.Picture,
|
||||
Background = background ?? realm.Background,
|
||||
RealmId = realm.Id,
|
||||
Members = new List<PublisherMember>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AccountId = account.Id,
|
||||
Role = PublisherMemberRole.Owner,
|
||||
JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
db.Publishers.Add(publisher);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (publisher.Picture is not null) await fs.MarkUsageAsync(publisher.Picture, 1);
|
||||
if (publisher.Background is not null) await fs.MarkUsageAsync(publisher.Background, 1);
|
||||
|
||||
return publisher;
|
||||
}
|
||||
|
||||
public class PublisherStats
|
||||
{
|
||||
public int PostsCreated { get; set; }
|
||||
public int StickerPacksCreated { get; set; }
|
||||
public int StickersCreated { get; set; }
|
||||
public int UpvoteReceived { get; set; }
|
||||
public int DownvoteReceived { get; set; }
|
||||
public int SubscribersCount { get; set; }
|
||||
}
|
||||
|
||||
private const string PublisherStatsCacheKey = "PublisherStats_{0}";
|
||||
|
||||
public async Task<PublisherStats?> GetPublisherStats(string name)
|
||||
{
|
||||
var cacheKey = string.Format(PublisherStatsCacheKey, name);
|
||||
if (cache.TryGetValue(cacheKey, out PublisherStats? stats))
|
||||
return stats;
|
||||
|
||||
var publisher = await db.Publishers.FirstOrDefaultAsync(e => e.Name == name);
|
||||
if (publisher is null) return null;
|
||||
|
||||
var postsCount = await db.Posts.Where(e => e.Publisher.Id == publisher.Id).CountAsync();
|
||||
var postsUpvotes = await db.PostReactions
|
||||
.Where(r => r.Post.Publisher.Id == publisher.Id && r.Attitude == PostReactionAttitude.Positive)
|
||||
.CountAsync();
|
||||
var postsDownvotes = await db.PostReactions
|
||||
.Where(r => r.Post.Publisher.Id == publisher.Id && r.Attitude == PostReactionAttitude.Negative)
|
||||
.CountAsync();
|
||||
|
||||
var stickerPacksId = await db.StickerPacks.Where(e => e.Publisher.Id == publisher.Id).Select(e => e.Id)
|
||||
.ToListAsync();
|
||||
var stickerPacksCount = stickerPacksId.Count;
|
||||
|
||||
var stickersCount = await db.Stickers.Where(e => stickerPacksId.Contains(e.PackId)).CountAsync();
|
||||
|
||||
var subscribersCount = await db.PublisherSubscriptions.Where(e => e.PublisherId == publisher.Id).CountAsync();
|
||||
|
||||
stats = new PublisherStats
|
||||
{
|
||||
PostsCreated = postsCount,
|
||||
StickerPacksCreated = stickerPacksCount,
|
||||
StickersCreated = stickersCount,
|
||||
UpvoteReceived = postsUpvotes,
|
||||
DownvoteReceived = postsDownvotes,
|
||||
SubscribersCount = subscribersCount,
|
||||
};
|
||||
|
||||
cache.Set(cacheKey, stats, TimeSpan.FromMinutes(5));
|
||||
return stats;
|
||||
}
|
||||
}
|
@ -1,111 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DysonNetwork.Sphere.Post;
|
||||
|
||||
[ApiController]
|
||||
[Route("/publishers")]
|
||||
public class PublisherSubscriptionController(
|
||||
PublisherSubscriptionService subs,
|
||||
AppDatabase db,
|
||||
ILogger<PublisherSubscriptionController> logger
|
||||
)
|
||||
: ControllerBase
|
||||
{
|
||||
public class SubscriptionStatusResponse
|
||||
{
|
||||
public bool IsSubscribed { get; set; }
|
||||
public Guid PublisherId { get; set; }
|
||||
public string PublisherName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class SubscribeRequest
|
||||
{
|
||||
public int? Tier { get; set; }
|
||||
}
|
||||
|
||||
[HttpGet("{name}/subscription")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SubscriptionStatusResponse>> CheckSubscriptionStatus(string name)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
// Check if the publisher exists
|
||||
var publisher = await db.Publishers.FirstOrDefaultAsync(p => p.Name == name);
|
||||
if (publisher == null)
|
||||
return NotFound("Publisher not found");
|
||||
|
||||
var isSubscribed = await subs.SubscriptionExistsAsync(currentUser.Id, publisher.Id);
|
||||
|
||||
return new SubscriptionStatusResponse
|
||||
{
|
||||
IsSubscribed = isSubscribed,
|
||||
PublisherId = publisher.Id,
|
||||
PublisherName = publisher.Name
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("{name}/subscribe")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<PublisherSubscription>> Subscribe(
|
||||
string name,
|
||||
[FromBody] SubscribeRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
// Check if the publisher exists
|
||||
var publisher = await db.Publishers.FirstOrDefaultAsync(p => p.Name == name);
|
||||
if (publisher == null)
|
||||
return NotFound("Publisher not found");
|
||||
|
||||
try
|
||||
{
|
||||
var subscription = await subs.CreateSubscriptionAsync(
|
||||
currentUser.Id,
|
||||
publisher.Id,
|
||||
request.Tier ?? 0
|
||||
);
|
||||
|
||||
return subscription;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error subscribing to publisher {PublisherName}", name);
|
||||
return StatusCode(500, "Failed to create subscription");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{name}/unsubscribe")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Unsubscribe(string name)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
// Check if the publisher exists
|
||||
var publisher = await db.Publishers.FirstOrDefaultAsync(e => e.Name == name);
|
||||
if (publisher == null)
|
||||
return NotFound("Publisher not found");
|
||||
|
||||
var success = await subs.CancelSubscriptionAsync(currentUser.Id, publisher.Id);
|
||||
|
||||
if (success)
|
||||
return Ok(new { message = "Subscription cancelled successfully" });
|
||||
|
||||
return NotFound("Active subscription not found");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all subscriptions for the current user
|
||||
/// </summary>
|
||||
/// <returns>List of active subscriptions</returns>
|
||||
[HttpGet("subscriptions")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<PublisherSubscription>>> GetCurrentSubscriptions()
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
var subscriptions = await subs.GetAccountSubscriptionsAsync(currentUser.Id);
|
||||
return subscriptions;
|
||||
}
|
||||
}
|
@ -1,181 +0,0 @@
|
||||
using DysonNetwork.Sphere.Account;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Post;
|
||||
|
||||
public class PublisherSubscriptionService(AppDatabase db, NotificationService nty)
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a subscription exists between the account and publisher
|
||||
/// </summary>
|
||||
/// <param name="accountId">The account ID</param>
|
||||
/// <param name="publisherId">The publisher ID</param>
|
||||
/// <returns>True if a subscription exists, false otherwise</returns>
|
||||
public async Task<bool> SubscriptionExistsAsync(Guid accountId, Guid publisherId)
|
||||
{
|
||||
return await db.PublisherSubscriptions
|
||||
.AnyAsync(ps => ps.AccountId == accountId &&
|
||||
ps.PublisherId == publisherId &&
|
||||
ps.Status == SubscriptionStatus.Active);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a subscription by account and publisher ID
|
||||
/// </summary>
|
||||
/// <param name="accountId">The account ID</param>
|
||||
/// <param name="publisherId">The publisher ID</param>
|
||||
/// <returns>The subscription or null if not found</returns>
|
||||
public async Task<PublisherSubscription?> GetSubscriptionAsync(Guid accountId, Guid publisherId)
|
||||
{
|
||||
return await db.PublisherSubscriptions
|
||||
.Include(ps => ps.Publisher)
|
||||
.FirstOrDefaultAsync(ps => ps.AccountId == accountId && ps.PublisherId == publisherId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Notifies all subscribers about a new post from a publisher
|
||||
/// </summary>
|
||||
/// <param name="post">The new post</param>
|
||||
/// <returns>The number of subscribers notified</returns>
|
||||
public async Task<int> NotifySubscribersPostAsync(Post post)
|
||||
{
|
||||
var subscribers = await db.PublisherSubscriptions
|
||||
.Include(ps => ps.Account)
|
||||
.Where(ps => ps.PublisherId == post.Publisher.Id &&
|
||||
ps.Status == SubscriptionStatus.Active)
|
||||
.ToListAsync();
|
||||
if (subscribers.Count == 0)
|
||||
return 0;
|
||||
|
||||
// Create notification data
|
||||
var title = $"@{post.Publisher.Name} Posted";
|
||||
var message = !string.IsNullOrEmpty(post.Title)
|
||||
? post.Title
|
||||
: (post.Content?.Length > 100
|
||||
? string.Concat(post.Content.AsSpan(0, 97), "...")
|
||||
: post.Content);
|
||||
|
||||
// Data to include with the notification
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "post_id", post.Id.ToString() },
|
||||
{ "publisher_id", post.Publisher.Id.ToString() }
|
||||
};
|
||||
|
||||
// Notify each subscriber
|
||||
var notifiedCount = 0;
|
||||
foreach (var subscription in subscribers)
|
||||
{
|
||||
try
|
||||
{
|
||||
await nty.SendNotification(
|
||||
subscription.Account,
|
||||
"posts.new",
|
||||
title,
|
||||
post.Description?.Length > 40 ? post.Description[..37] + "..." : post.Description,
|
||||
message,
|
||||
data
|
||||
);
|
||||
|
||||
notifiedCount++;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Log the error but continue with other notifications
|
||||
// We don't want one failed notification to stop the others
|
||||
}
|
||||
}
|
||||
|
||||
return notifiedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active subscriptions for an account
|
||||
/// </summary>
|
||||
/// <param name="accountId">The account ID</param>
|
||||
/// <returns>A list of active subscriptions</returns>
|
||||
public async Task<List<PublisherSubscription>> GetAccountSubscriptionsAsync(Guid accountId)
|
||||
{
|
||||
return await db.PublisherSubscriptions
|
||||
.Include(ps => ps.Publisher)
|
||||
.Where(ps => ps.AccountId == accountId && ps.Status == SubscriptionStatus.Active)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all active subscribers for a publisher
|
||||
/// </summary>
|
||||
/// <param name="publisherId">The publisher ID</param>
|
||||
/// <returns>A list of active subscriptions</returns>
|
||||
public async Task<List<PublisherSubscription>> GetPublisherSubscribersAsync(Guid publisherId)
|
||||
{
|
||||
return await db.PublisherSubscriptions
|
||||
.Include(ps => ps.Account)
|
||||
.Where(ps => ps.PublisherId == publisherId && ps.Status == SubscriptionStatus.Active)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new subscription between an account and a publisher
|
||||
/// </summary>
|
||||
/// <param name="accountId">The account ID</param>
|
||||
/// <param name="publisherId">The publisher ID</param>
|
||||
/// <param name="tier">Optional subscription tier</param>
|
||||
/// <returns>The created subscription</returns>
|
||||
public async Task<PublisherSubscription> CreateSubscriptionAsync(
|
||||
Guid accountId,
|
||||
Guid publisherId,
|
||||
int tier = 0
|
||||
)
|
||||
{
|
||||
// Check if a subscription already exists
|
||||
var existingSubscription = await GetSubscriptionAsync(accountId, publisherId);
|
||||
|
||||
if (existingSubscription != null)
|
||||
{
|
||||
// If it exists but is not active, reactivate it
|
||||
if (existingSubscription.Status == SubscriptionStatus.Active) return existingSubscription;
|
||||
existingSubscription.Status = SubscriptionStatus.Active;
|
||||
existingSubscription.Tier = tier;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return existingSubscription;
|
||||
|
||||
// If it's already active, just return it
|
||||
}
|
||||
|
||||
// Create a new subscription
|
||||
var subscription = new PublisherSubscription
|
||||
{
|
||||
AccountId = accountId,
|
||||
PublisherId = publisherId,
|
||||
Status = SubscriptionStatus.Active,
|
||||
Tier = tier,
|
||||
};
|
||||
|
||||
db.PublisherSubscriptions.Add(subscription);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a subscription
|
||||
/// </summary>
|
||||
/// <param name="accountId">The account ID</param>
|
||||
/// <param name="publisherId">The publisher ID</param>
|
||||
/// <returns>True if the subscription was cancelled, false if it wasn't found</returns>
|
||||
public async Task<bool> CancelSubscriptionAsync(Guid accountId, Guid publisherId)
|
||||
{
|
||||
var subscription = await GetSubscriptionAsync(accountId, publisherId);
|
||||
if (subscription is not { Status: SubscriptionStatus.Active })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
subscription.Status = SubscriptionStatus.Cancelled;
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user