✨ Organization publishers, subscriptions to publishers
This commit is contained in:
@ -11,7 +11,7 @@ namespace DysonNetwork.Sphere.Post;
|
||||
|
||||
[ApiController]
|
||||
[Route("/posts")]
|
||||
public class PostController(AppDatabase db, PostService ps, RelationshipService rels) : ControllerBase
|
||||
public class PostController(AppDatabase db, PostService ps, RelationshipService rels, IServiceScopeFactory factory) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<Post>>> ListPosts([FromQuery] int offset = 0, [FromQuery] int take = 20)
|
||||
@ -205,6 +205,13 @@ public class PostController(AppDatabase db, PostService ps, RelationshipService
|
||||
return BadRequest(err.Message);
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
using var scope = factory.CreateScope();
|
||||
var subs = scope.ServiceProvider.GetRequiredService<PublisherSubscriptionService>();
|
||||
await subs.NotifySubscribersPostAsync(post);
|
||||
});
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
|
@ -30,9 +30,14 @@ public class Publisher : ModelBase
|
||||
[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 long? AccountId { get; set; }
|
||||
[JsonIgnore] public Account.Account? Account { get; set; }
|
||||
public long? RealmId { get; set; }
|
||||
[JsonIgnore] public Realm.Realm? Realm { get; set; }
|
||||
}
|
||||
|
||||
public enum PublisherMemberRole
|
||||
@ -52,4 +57,24 @@ public class PublisherMember : ModelBase
|
||||
|
||||
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 long PublisherId { get; set; }
|
||||
[JsonIgnore] public Publisher Publisher { get; set; } = null!;
|
||||
public long 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,5 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using DysonNetwork.Sphere.Permission;
|
||||
using DysonNetwork.Sphere.Realm;
|
||||
using DysonNetwork.Sphere.Storage;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -168,11 +169,11 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
|
||||
public string? PictureId { get; set; }
|
||||
public string? BackgroundId { get; set; }
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("individual")]
|
||||
[Authorize]
|
||||
[RequiredPermission("global", "publishers.create")]
|
||||
public async Task<ActionResult<Publisher>> CreatePublisherIndividual(PublisherRequest request)
|
||||
public async Task<ActionResult<Publisher>> CreatePublisherIndividual([FromBody] PublisherRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
@ -212,6 +213,55 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic
|
||||
|
||||
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]
|
||||
|
@ -45,7 +45,44 @@ public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache
|
||||
return publisher;
|
||||
}
|
||||
|
||||
// TODO Able to create organizational publisher when the realm system is completed
|
||||
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
|
||||
{
|
||||
@ -54,19 +91,20 @@ public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache
|
||||
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)
|
||||
@ -74,21 +112,25 @@ public class PublisherService(AppDatabase db, FileService fs, IMemoryCache cache
|
||||
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 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
|
||||
DownvoteReceived = postsDownvotes,
|
||||
SubscribersCount = subscribersCount,
|
||||
};
|
||||
|
||||
|
||||
cache.Set(cacheKey, stats, TimeSpan.FromMinutes(5));
|
||||
return stats;
|
||||
}
|
||||
|
126
DysonNetwork.Sphere/Post/PublisherSubscriptionController.cs
Normal file
126
DysonNetwork.Sphere/Post/PublisherSubscriptionController.cs
Normal file
@ -0,0 +1,126 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DysonNetwork.Sphere.Post;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class PublisherSubscriptionController(
|
||||
PublisherSubscriptionService subs,
|
||||
AppDatabase db,
|
||||
ILogger<PublisherSubscriptionController> logger
|
||||
)
|
||||
: ControllerBase
|
||||
{
|
||||
public class SubscriptionStatusResponse
|
||||
{
|
||||
public bool IsSubscribed { get; set; }
|
||||
public long PublisherId { get; set; }
|
||||
public string PublisherName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class SubscribeRequest
|
||||
{
|
||||
public int? Tier { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if the current user is subscribed to a publisher
|
||||
/// </summary>
|
||||
/// <param name="publisherId">Publisher ID to check</param>
|
||||
/// <returns>Subscription status</returns>
|
||||
[HttpGet("{publisherId}/status")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<SubscriptionStatusResponse>> CheckSubscriptionStatus(long publisherId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
// Check if the publisher exists
|
||||
var publisher = await db.Publishers.FindAsync(publisherId);
|
||||
if (publisher == null)
|
||||
return NotFound("Publisher not found");
|
||||
|
||||
var isSubscribed = await subs.SubscriptionExistsAsync(currentUser.Id, publisherId);
|
||||
|
||||
return new SubscriptionStatusResponse
|
||||
{
|
||||
IsSubscribed = isSubscribed,
|
||||
PublisherId = publisherId,
|
||||
PublisherName = publisher.Name
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create or activate a subscription to a publisher
|
||||
/// </summary>
|
||||
/// <param name="publisherId">Publisher ID to subscribe to</param>
|
||||
/// <param name="request">Subscription details</param>
|
||||
/// <returns>The created or activated subscription</returns>
|
||||
[HttpPost("{publisherId}/subscribe")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<PublisherSubscription>> Subscribe(
|
||||
long publisherId,
|
||||
[FromBody] SubscribeRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
// Check if the publisher exists
|
||||
var publisher = await db.Publishers.FindAsync(publisherId);
|
||||
if (publisher == null)
|
||||
return NotFound("Publisher not found");
|
||||
|
||||
try
|
||||
{
|
||||
var subscription = await subs.CreateSubscriptionAsync(
|
||||
currentUser.Id,
|
||||
publisherId,
|
||||
request.Tier ?? 0
|
||||
);
|
||||
|
||||
return subscription;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error subscribing to publisher {PublisherId}", publisherId);
|
||||
return StatusCode(500, "Failed to create subscription");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancel a subscription to a publisher
|
||||
/// </summary>
|
||||
/// <param name="publisherId">Publisher ID to unsubscribe from</param>
|
||||
/// <returns>Success status</returns>
|
||||
[HttpPost("{publisherId}/unsubscribe")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> Unsubscribe(long publisherId)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized();
|
||||
|
||||
// Check if the publisher exists
|
||||
var publisher = await db.Publishers.FindAsync(publisherId);
|
||||
if (publisher == null)
|
||||
return NotFound("Publisher not found");
|
||||
|
||||
var success = await subs.CancelSubscriptionAsync(currentUser.Id, publisherId);
|
||||
|
||||
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("current")]
|
||||
[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;
|
||||
}
|
||||
}
|
181
DysonNetwork.Sphere/Post/PublisherSubscriptionService.cs
Normal file
181
DysonNetwork.Sphere/Post/PublisherSubscriptionService.cs
Normal file
@ -0,0 +1,181 @@
|
||||
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(long accountId, long 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(long accountId, long 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(long 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(long 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(
|
||||
long accountId,
|
||||
long 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(long accountId, long 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