✨ Organization publishers, subscriptions to publishers
This commit is contained in:
parent
b20bc3c443
commit
b275f06061
@ -44,6 +44,7 @@ public class AppDatabase(
|
||||
|
||||
public DbSet<Post.Publisher> Publishers { get; set; }
|
||||
public DbSet<Post.PublisherMember> PublisherMembers { get; set; }
|
||||
public DbSet<Post.PublisherSubscription> PublisherSubscriptions { get; set; }
|
||||
public DbSet<Post.Post> Posts { get; set; }
|
||||
public DbSet<Post.PostReaction> PostReactions { get; set; }
|
||||
public DbSet<Post.PostTag> PostTags { get; set; }
|
||||
@ -148,6 +149,16 @@ public class AppDatabase(
|
||||
.WithMany()
|
||||
.HasForeignKey(pm => pm.AccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
modelBuilder.Entity<Post.PublisherSubscription>()
|
||||
.HasOne(ps => ps.Publisher)
|
||||
.WithMany(p => p.Subscriptions)
|
||||
.HasForeignKey(ps => ps.PublisherId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
modelBuilder.Entity<Post.PublisherSubscription>()
|
||||
.HasOne(ps => ps.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(ps => ps.AccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<Post.Post>()
|
||||
.HasGeneratedTsVectorColumn(p => p.SearchVector, "simple", p => new { p.Title, p.Description, p.Content })
|
||||
|
2763
DysonNetwork.Sphere/Migrations/20250512132355_AddPublisherSubscription.Designer.cs
generated
Normal file
2763
DysonNetwork.Sphere/Migrations/20250512132355_AddPublisherSubscription.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Sphere.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPublisherSubscription : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "publisher_subscriptions",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
publisher_id = table.Column<long>(type: "bigint", nullable: false),
|
||||
account_id = table.Column<long>(type: "bigint", nullable: false),
|
||||
status = table.Column<int>(type: "integer", nullable: false),
|
||||
tier = table.Column<int>(type: "integer", 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_subscriptions", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_publisher_subscriptions_accounts_account_id",
|
||||
column: x => x.account_id,
|
||||
principalTable: "accounts",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "fk_publisher_subscriptions_publishers_publisher_id",
|
||||
column: x => x.publisher_id,
|
||||
principalTable: "publishers",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_publisher_subscriptions_account_id",
|
||||
table: "publisher_subscriptions",
|
||||
column: "account_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_publisher_subscriptions_publisher_id",
|
||||
table: "publisher_subscriptions",
|
||||
column: "publisher_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "publisher_subscriptions");
|
||||
}
|
||||
}
|
||||
}
|
2777
DysonNetwork.Sphere/Migrations/20250512133934_PublisherWithOrganization.Designer.cs
generated
Normal file
2777
DysonNetwork.Sphere/Migrations/20250512133934_PublisherWithOrganization.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DysonNetwork.Sphere.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PublisherWithOrganization : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "realm_id",
|
||||
table: "publishers",
|
||||
type: "bigint",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_publishers_realm_id",
|
||||
table: "publishers",
|
||||
column: "realm_id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "fk_publishers_realms_realm_id",
|
||||
table: "publishers",
|
||||
column: "realm_id",
|
||||
principalTable: "realms",
|
||||
principalColumn: "id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "fk_publishers_realms_realm_id",
|
||||
table: "publishers");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "ix_publishers_realm_id",
|
||||
table: "publishers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "realm_id",
|
||||
table: "publishers");
|
||||
}
|
||||
}
|
||||
}
|
@ -1575,6 +1575,10 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("publisher_type");
|
||||
|
||||
b.Property<long?>("RealmId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("realm_id");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
@ -1595,6 +1599,9 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.HasIndex("PictureId")
|
||||
.HasDatabaseName("ix_publishers_picture_id");
|
||||
|
||||
b.HasIndex("RealmId")
|
||||
.HasDatabaseName("ix_publishers_realm_id");
|
||||
|
||||
b.ToTable("publishers", (string)null);
|
||||
});
|
||||
|
||||
@ -1637,6 +1644,53 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.ToTable("publisher_members", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Post.PublisherSubscription", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("AccountId")
|
||||
.HasColumnType("bigint")
|
||||
.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<long>("PublisherId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("publisher_id");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<int>("Tier")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("tier");
|
||||
|
||||
b.Property<Instant>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_publisher_subscriptions");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.HasDatabaseName("ix_publisher_subscriptions_account_id");
|
||||
|
||||
b.HasIndex("PublisherId")
|
||||
.HasDatabaseName("ix_publisher_subscriptions_publisher_id");
|
||||
|
||||
b.ToTable("publisher_subscriptions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Realm.Realm", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@ -2442,11 +2496,18 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
.HasForeignKey("PictureId")
|
||||
.HasConstraintName("fk_publishers_files_picture_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Sphere.Realm.Realm", "Realm")
|
||||
.WithMany()
|
||||
.HasForeignKey("RealmId")
|
||||
.HasConstraintName("fk_publishers_realms_realm_id");
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Background");
|
||||
|
||||
b.Navigation("Picture");
|
||||
|
||||
b.Navigation("Realm");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Post.PublisherMember", b =>
|
||||
@ -2470,6 +2531,27 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.Navigation("Publisher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Post.PublisherSubscription", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_publisher_subscriptions_accounts_account_id");
|
||||
|
||||
b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher")
|
||||
.WithMany("Subscriptions")
|
||||
.HasForeignKey("PublisherId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_publisher_subscriptions_publishers_publisher_id");
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Publisher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Realm.Realm", b =>
|
||||
{
|
||||
b.HasOne("DysonNetwork.Sphere.Account.Account", "Account")
|
||||
@ -2676,6 +2758,8 @@ namespace DysonNetwork.Sphere.Migrations
|
||||
b.Navigation("Members");
|
||||
|
||||
b.Navigation("Posts");
|
||||
|
||||
b.Navigation("Subscriptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DysonNetwork.Sphere.Realm.Realm", b =>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -157,6 +157,7 @@ builder.Services.AddScoped<NotificationService>();
|
||||
builder.Services.AddScoped<AuthService>();
|
||||
builder.Services.AddScoped<FileService>();
|
||||
builder.Services.AddScoped<PublisherService>();
|
||||
builder.Services.AddScoped<PublisherSubscriptionService>();
|
||||
builder.Services.AddScoped<ActivityService>();
|
||||
builder.Services.AddScoped<ActivityReaderService>();
|
||||
builder.Services.AddScoped<PostService>();
|
||||
|
@ -71,21 +71,21 @@ public class StickerService(AppDatabase db, FileService fs, IMemoryCache cache)
|
||||
|
||||
public async Task<Sticker?> LookupStickerByIdentifierAsync(string identifier)
|
||||
{
|
||||
// Try to get from cache first
|
||||
string cacheKey = $"StickerLookup_{identifier}";
|
||||
identifier = identifier.ToLower();
|
||||
// Try to get from the cache first
|
||||
var cacheKey = $"StickerLookup_{identifier}";
|
||||
if (cache.TryGetValue(cacheKey, out Sticker? cachedSticker))
|
||||
{
|
||||
return cachedSticker;
|
||||
}
|
||||
|
||||
// If not in cache, fetch from database
|
||||
// If not in cache, fetch from the database
|
||||
IQueryable<Sticker> query = db.Stickers
|
||||
.Include(e => e.Pack)
|
||||
.Include(e => e.Image);
|
||||
|
||||
query = Guid.TryParse(identifier, out var guid)
|
||||
? query.Where(e => e.Id == guid)
|
||||
: query.Where(e => e.Pack.Prefix + e.Slug == identifier);
|
||||
: query.Where(e => (e.Pack.Prefix + e.Slug).ToLower() == identifier);
|
||||
|
||||
var sticker = await query.FirstOrDefaultAsync();
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user