diff --git a/DysonNetwork.Sphere/Account/Notification.cs b/DysonNetwork.Sphere/Account/Notification.cs new file mode 100644 index 0000000..7015d4c --- /dev/null +++ b/DysonNetwork.Sphere/Account/Notification.cs @@ -0,0 +1,41 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Sphere.Account; + +public class Notification : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(1024)] public string? Title { get; set; } + [MaxLength(2048)] public string? Subtitle { get; set; } + [MaxLength(4096)] public string? Content { get; set; } + [Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } + public int Priority { get; set; } = 10; + public Instant? ViewedAt { get; set; } + + public long AccountId { get; set; } + [JsonIgnore] public Account Account { get; set; } = null!; +} + +public enum NotificationPushProvider +{ + Apple, + Google +} + +[Index(nameof(DeviceId), IsUnique = true)] +[Index(nameof(DeviceToken), IsUnique = true)] +public class NotificationPushSubscription : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + [MaxLength(4096)] public string DeviceId { get; set; } = null!; + [MaxLength(4096)] public string DeviceToken { get; set; } = null!; + public NotificationPushProvider Provider { get; set; } + public Instant? LastUsedAt { get; set; } + + public long AccountId { get; set; } + [JsonIgnore] public Account Account { get; set; } = null!; +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Account/NotificationService.cs b/DysonNetwork.Sphere/Account/NotificationService.cs new file mode 100644 index 0000000..0082f1f --- /dev/null +++ b/DysonNetwork.Sphere/Account/NotificationService.cs @@ -0,0 +1,182 @@ +using CorePush.Apple; +using CorePush.Firebase; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Sphere.Account; + +public class NotificationService +{ + private readonly AppDatabase _db; + private readonly ILogger<NotificationService> _logger; + private readonly FirebaseSender? _fcm; + private readonly ApnSender? _apns; + + public NotificationService( + AppDatabase db, + IConfiguration cfg, + IHttpClientFactory clientFactory, + ILogger<NotificationService> logger + ) + { + _db = db; + _logger = logger; + + var cfgSection = cfg.GetSection("Notifications:Push"); + + // Set up the firebase push notification + var fcmConfig = cfgSection.GetValue<string>("Google"); + if (fcmConfig != null) + _fcm = new FirebaseSender(File.ReadAllText(fcmConfig), clientFactory.CreateClient()); + // Set up the apple push notification service + var apnsCert = cfgSection.GetValue<string>("Apple:PrivateKey"); + if (apnsCert != null) + _apns = new ApnSender(new ApnSettings + { + P8PrivateKey = File.ReadAllText(apnsCert), + P8PrivateKeyId = cfgSection.GetValue<string>("Apple:PrivateKeyId"), + TeamId = cfgSection.GetValue<string>("Apple:TeamId"), + AppBundleIdentifier = cfgSection.GetValue<string>("Apple:BundleIdentifier"), + ServerType = cfgSection.GetValue<bool>("Production") + ? ApnServerType.Production + : ApnServerType.Development + }, clientFactory.CreateClient()); + } + + public async Task<NotificationPushSubscription> SubscribePushNotification( + Account account, + NotificationPushProvider provider, + string deviceId, + string deviceToken + ) + { + var existingSubscription = await _db.NotificationPushSubscriptions + .Where(s => s.AccountId == account.Id) + .Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken) + .FirstOrDefaultAsync(); + + if (existingSubscription != null) + { + // Reset these audit fields to renew the lifecycle of this device token + existingSubscription.CreatedAt = Instant.FromDateTimeUtc(DateTime.UtcNow); + existingSubscription.UpdatedAt = Instant.FromDateTimeUtc(DateTime.UtcNow); + _db.Update(existingSubscription); + await _db.SaveChangesAsync(); + return existingSubscription; + } + + var subscription = new NotificationPushSubscription + { + DeviceId = deviceId, + DeviceToken = deviceToken, + Provider = provider, + Account = account, + AccountId = account.Id, + }; + + _db.Add(subscription); + await _db.SaveChangesAsync(); + + return subscription; + } + + public async Task<Notification> SendNotification( + Account account, + string? title = null, + string? subtitle = null, + string? content = null, + Dictionary<string, object>? meta = null, + bool isSilent = false + ) + { + if (title is null && subtitle is null && content is null) + { + throw new ArgumentException("Unable to send notification that completely empty."); + } + + var notification = new Notification + { + Title = title, + Subtitle = subtitle, + Content = content, + Meta = meta, + Account = account, + AccountId = account.Id, + }; + + _db.Add(notification); + await _db.SaveChangesAsync(); + +#pragma warning disable CS4014 + if (!isSilent) DeliveryNotification(notification); +#pragma warning restore CS4014 + + return notification; + } + + public async Task DeliveryNotification(Notification notification) + { + // TODO send websocket + + // Pushing the notification + var subscribers = await _db.NotificationPushSubscriptions + .Where(s => s.AccountId == notification.AccountId) + .ToListAsync(); + + var tasks = new List<Task>(); + foreach (var subscriber in subscribers) + { + tasks.Add(_PushSingleNotification(notification, subscriber)); + } + + await Task.WhenAll(tasks); + } + + private async Task _PushSingleNotification(Notification notification, NotificationPushSubscription subscription) + { + switch (subscription.Provider) + { + case NotificationPushProvider.Google: + if (_fcm == null) + throw new InvalidOperationException("The firebase cloud messaging is not initialized."); + await _fcm.SendAsync(new + { + message = new + { + token = subscription.DeviceToken, + notification = new + { + title = notification.Title, + body = string.Join("\n", notification.Subtitle, notification.Content), + }, + data = notification.Meta + } + }); + break; + case NotificationPushProvider.Apple: + if (_apns == null) + throw new InvalidOperationException("The apple notification push service is not initialized."); + await _apns.SendAsync(new + { + apns = new + { + alert = new + { + title = notification.Title, + subtitle = notification.Subtitle, + content = notification.Content, + } + }, + meta = notification.Meta, + }, + deviceToken: subscription.DeviceToken, + apnsId: notification.Id.ToString(), + apnsPriority: notification.Priority, + apnPushType: ApnPushType.Alert + ); + break; + default: + throw new InvalidOperationException($"Provider not supported: {subscription.Provider}"); + } + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/AppDatabase.cs b/DysonNetwork.Sphere/AppDatabase.cs index c0f6f2a..cfb32c2 100644 --- a/DysonNetwork.Sphere/AppDatabase.cs +++ b/DysonNetwork.Sphere/AppDatabase.cs @@ -26,6 +26,8 @@ public class AppDatabase( public DbSet<Account.Relationship> AccountRelationships { get; set; } public DbSet<Auth.Session> AuthSessions { get; set; } public DbSet<Auth.Challenge> AuthChallenges { get; set; } + public DbSet<Account.Notification> Notifications { get; set; } + public DbSet<Account.NotificationPushSubscription> NotificationPushSubscriptions { get; set; } public DbSet<Storage.CloudFile> Files { get; set; } public DbSet<Post.Publisher> Publishers { get; set; } public DbSet<Post.PublisherMember> PublisherMembers { get; set; } diff --git a/DysonNetwork.Sphere/Auth/Session.cs b/DysonNetwork.Sphere/Auth/Session.cs index 373972a..508c414 100644 --- a/DysonNetwork.Sphere/Auth/Session.cs +++ b/DysonNetwork.Sphere/Auth/Session.cs @@ -29,6 +29,7 @@ public class Challenge : ModelBase [MaxLength(256)] public string? DeviceId { get; set; } [MaxLength(1024)] public string? Nonce { get; set; } + public long AccountId { get; set; } [JsonIgnore] public Account.Account Account { get; set; } = null!; public Challenge Normalize() diff --git a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj index c0a3d83..66bdb9c 100644 --- a/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj +++ b/DysonNetwork.Sphere/DysonNetwork.Sphere.csproj @@ -13,6 +13,7 @@ <PackageReference Include="Blurhash.ImageSharp" Version="4.0.0" /> <PackageReference Include="Casbin.NET" Version="2.12.0" /> <PackageReference Include="Casbin.NET.Adapter.EFCore" Version="2.5.0" /> + <PackageReference Include="CorePush" Version="4.3.0" /> <PackageReference Include="EFCore.NamingConventions" Version="9.0.0" /> <PackageReference Include="FFMpegCore" Version="5.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.4" /> diff --git a/DysonNetwork.Sphere/Migrations/20250427154356_AddNotification.Designer.cs b/DysonNetwork.Sphere/Migrations/20250427154356_AddNotification.Designer.cs new file mode 100644 index 0000000..814e2b0 --- /dev/null +++ b/DysonNetwork.Sphere/Migrations/20250427154356_AddNotification.Designer.cs @@ -0,0 +1,1406 @@ +// <auto-generated /> +using System; +using System.Collections.Generic; +using DysonNetwork.Sphere; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DysonNetwork.Sphere.Migrations +{ + [DbContext(typeof(AppDatabase))] + [Migration("20250427154356_AddNotification")] + partial class AddNotification + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("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<bool>("IsSuperuser") + .HasColumnType("boolean") + .HasColumnName("is_superuser"); + + b.Property<string>("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("language"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property<string>("Nick") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("nick"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_accounts_name"); + + b.ToTable("accounts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("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<string>("Secret") + .HasColumnType("text") + .HasColumnName("secret"); + + 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_account_auth_factors"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_account_auth_factors_account_id"); + + b.ToTable("account_auth_factors", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property<string>("Content") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("content"); + + 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<int>("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property<Instant?>("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.HasKey("Id") + .HasName("pk_account_contacts"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_account_contacts_account_id"); + + b.ToTable("account_contacts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Notification", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<long>("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property<string>("Content") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("content"); + + 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<Dictionary<string, object>>("Meta") + .HasColumnType("jsonb") + .HasColumnName("meta"); + + b.Property<int>("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property<string>("Subtitle") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("subtitle"); + + b.Property<string>("Title") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("title"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property<Instant?>("ViewedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("viewed_at"); + + b.HasKey("Id") + .HasName("pk_notifications"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_notifications_account_id"); + + b.ToTable("notifications", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.NotificationPushSubscription", 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<string>("DeviceId") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("device_id"); + + b.Property<string>("DeviceToken") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("device_token"); + + b.Property<Instant?>("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property<int>("Provider") + .HasColumnType("integer") + .HasColumnName("provider"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_push_subscriptions"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_notification_push_subscriptions_account_id"); + + b.HasIndex("DeviceId") + .IsUnique() + .HasDatabaseName("ix_notification_push_subscriptions_device_id"); + + b.HasIndex("DeviceToken") + .IsUnique() + .HasDatabaseName("ix_notification_push_subscriptions_device_token"); + + b.ToTable("notification_push_subscriptions", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Profile", b => + { + b.Property<long>("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property<string>("BackgroundId") + .HasColumnType("character varying(128)") + .HasColumnName("background_id"); + + b.Property<string>("Bio") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("bio"); + + 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>("FirstName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("first_name"); + + b.Property<string>("LastName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("last_name"); + + b.Property<string>("MiddleName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("middle_name"); + + b.Property<string>("PictureId") + .HasColumnType("character varying(128)") + .HasColumnName("picture_id"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_account_profiles"); + + b.HasIndex("BackgroundId") + .HasDatabaseName("ix_account_profiles_background_id"); + + b.HasIndex("PictureId") + .HasDatabaseName("ix_account_profiles_picture_id"); + + b.ToTable("account_profiles", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b => + { + b.Property<long>("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property<long>("RelatedId") + .HasColumnType("bigint") + .HasColumnName("related_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<int>("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("AccountId", "RelatedId") + .HasName("pk_account_relationships"); + + b.HasIndex("RelatedId") + .HasDatabaseName("ix_account_relationships_related_id"); + + b.ToTable("account_relationships", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<long>("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property<List<string>>("Audiences") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("audiences"); + + b.Property<List<long>>("BlacklistFactors") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("blacklist_factors"); + + 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>("DeviceId") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("device_id"); + + b.Property<Instant?>("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property<string>("IpAddress") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("ip_address"); + + b.Property<string>("Nonce") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("nonce"); + + b.Property<List<string>>("Scopes") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("scopes"); + + b.Property<int>("StepRemain") + .HasColumnType("integer") + .HasColumnName("step_remain"); + + b.Property<int>("StepTotal") + .HasColumnType("integer") + .HasColumnName("step_total"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property<string>("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("user_agent"); + + b.HasKey("Id") + .HasName("pk_auth_challenges"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_auth_challenges_account_id"); + + b.ToTable("auth_challenges", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Auth.Session", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<long>("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property<Guid>("ChallengeId") + .HasColumnType("uuid") + .HasColumnName("challenge_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<Instant?>("LastGrantedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_granted_at"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_auth_sessions"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_auth_sessions_account_id"); + + b.HasIndex("ChallengeId") + .HasDatabaseName("ix_auth_sessions_challenge_id"); + + b.ToTable("auth_sessions", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<string>("Content") + .HasColumnType("text") + .HasColumnName("content"); + + 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>("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property<int>("Downvotes") + .HasColumnType("integer") + .HasColumnName("downvotes"); + + b.Property<Instant?>("EditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("edited_at"); + + b.Property<long?>("ForwardedPostId") + .HasColumnType("bigint") + .HasColumnName("forwarded_post_id"); + + b.Property<string>("Language") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("language"); + + b.Property<Dictionary<string, object>>("Meta") + .HasColumnType("jsonb") + .HasColumnName("meta"); + + b.Property<Instant?>("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + + b.Property<long>("PublisherId") + .HasColumnType("bigint") + .HasColumnName("publisher_id"); + + b.Property<long?>("RepliedPostId") + .HasColumnType("bigint") + .HasColumnName("replied_post_id"); + + b.Property<long?>("ThreadedPostId") + .HasColumnType("bigint") + .HasColumnName("threaded_post_id"); + + b.Property<string>("Title") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("title"); + + b.Property<int>("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property<int>("Upvotes") + .HasColumnType("integer") + .HasColumnName("upvotes"); + + b.Property<int>("ViewsTotal") + .HasColumnType("integer") + .HasColumnName("views_total"); + + b.Property<int>("ViewsUnique") + .HasColumnType("integer") + .HasColumnName("views_unique"); + + b.Property<int>("Visibility") + .HasColumnType("integer") + .HasColumnName("visibility"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("ForwardedPostId") + .HasDatabaseName("ix_posts_forwarded_post_id"); + + b.HasIndex("PublisherId") + .HasDatabaseName("ix_posts_publisher_id"); + + b.HasIndex("RepliedPostId") + .HasDatabaseName("ix_posts_replied_post_id"); + + b.HasIndex("ThreadedPostId") + .IsUnique() + .HasDatabaseName("ix_posts_threaded_post_id"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCategory", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("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") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property<string>("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("slug"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_categories"); + + b.ToTable("post_categories", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCollection", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("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>("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property<string>("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property<long>("PublisherId") + .HasColumnType("bigint") + .HasColumnName("publisher_id"); + + b.Property<string>("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("slug"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_collections"); + + b.HasIndex("PublisherId") + .HasDatabaseName("ix_post_collections_publisher_id"); + + b.ToTable("post_collections", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostReaction", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long>("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property<int>("Attitude") + .HasColumnType("integer") + .HasColumnName("attitude"); + + 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>("PostId") + .HasColumnType("bigint") + .HasColumnName("post_id"); + + b.Property<string>("Symbol") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("symbol"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_reactions"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_post_reactions_account_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_reactions_post_id"); + + b.ToTable("post_reactions", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostTag", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("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") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property<string>("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("slug"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_tags"); + + b.ToTable("post_tags", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b => + { + b.Property<long>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); + + b.Property<long?>("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property<string>("BackgroundId") + .HasColumnType("character varying(128)") + .HasColumnName("background_id"); + + b.Property<string>("Bio") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("bio"); + + 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(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property<string>("Nick") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("nick"); + + b.Property<string>("PictureId") + .HasColumnType("character varying(128)") + .HasColumnName("picture_id"); + + b.Property<int>("PublisherType") + .HasColumnType("integer") + .HasColumnName("publisher_type"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_publishers"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_publishers_account_id"); + + b.HasIndex("BackgroundId") + .HasDatabaseName("ix_publishers_background_id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_publishers_name"); + + b.HasIndex("PictureId") + .HasDatabaseName("ix_publishers_picture_id"); + + b.ToTable("publishers", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PublisherMember", b => + { + b.Property<long>("PublisherId") + .HasColumnType("bigint") + .HasColumnName("publisher_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<Instant?>("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property<int>("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("PublisherId", "AccountId") + .HasName("pk_publisher_members"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_publisher_members_account_id"); + + b.ToTable("publisher_members", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Storage.CloudFile", b => + { + b.Property<string>("Id") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .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<string>("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property<Instant?>("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property<Dictionary<string, object>>("FileMeta") + .HasColumnType("jsonb") + .HasColumnName("file_meta"); + + b.Property<string>("Hash") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("hash"); + + b.Property<string>("MimeType") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("mime_type"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property<long?>("PostId") + .HasColumnType("bigint") + .HasColumnName("post_id"); + + b.Property<long>("Size") + .HasColumnType("bigint") + .HasColumnName("size"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property<Instant?>("UploadedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at"); + + b.Property<string>("UploadedTo") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("uploaded_to"); + + b.Property<int>("UsedCount") + .HasColumnType("integer") + .HasColumnName("used_count"); + + b.Property<Dictionary<string, object>>("UserMeta") + .HasColumnType("jsonb") + .HasColumnName("user_meta"); + + b.HasKey("Id") + .HasName("pk_files"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_files_account_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_files_post_id"); + + b.ToTable("files", (string)null); + }); + + modelBuilder.Entity("PostPostCategory", b => + { + b.Property<long>("CategoriesId") + .HasColumnType("bigint") + .HasColumnName("categories_id"); + + b.Property<long>("PostsId") + .HasColumnType("bigint") + .HasColumnName("posts_id"); + + b.HasKey("CategoriesId", "PostsId") + .HasName("pk_post_category_links"); + + b.HasIndex("PostsId") + .HasDatabaseName("ix_post_category_links_posts_id"); + + b.ToTable("post_category_links", (string)null); + }); + + modelBuilder.Entity("PostPostCollection", b => + { + b.Property<long>("CollectionsId") + .HasColumnType("bigint") + .HasColumnName("collections_id"); + + b.Property<long>("PostsId") + .HasColumnType("bigint") + .HasColumnName("posts_id"); + + b.HasKey("CollectionsId", "PostsId") + .HasName("pk_post_collection_links"); + + b.HasIndex("PostsId") + .HasDatabaseName("ix_post_collection_links_posts_id"); + + b.ToTable("post_collection_links", (string)null); + }); + + modelBuilder.Entity("PostPostTag", b => + { + b.Property<long>("PostsId") + .HasColumnType("bigint") + .HasColumnName("posts_id"); + + b.Property<long>("TagsId") + .HasColumnType("bigint") + .HasColumnName("tags_id"); + + b.HasKey("PostsId", "TagsId") + .HasName("pk_post_tag_links"); + + b.HasIndex("TagsId") + .HasDatabaseName("ix_post_tag_links_tags_id"); + + b.ToTable("post_tag_links", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany("AuthFactors") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_auth_factors_accounts_account_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_contacts_accounts_account_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Notification", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifications_accounts_account_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.NotificationPushSubscription", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notification_push_subscriptions_accounts_account_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Profile", b => + { + b.HasOne("DysonNetwork.Sphere.Storage.CloudFile", "Background") + .WithMany() + .HasForeignKey("BackgroundId") + .HasConstraintName("fk_account_profiles_files_background_id"); + + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithOne("Profile") + .HasForeignKey("DysonNetwork.Sphere.Account.Profile", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_profiles_accounts_id"); + + b.HasOne("DysonNetwork.Sphere.Storage.CloudFile", "Picture") + .WithMany() + .HasForeignKey("PictureId") + .HasConstraintName("fk_account_profiles_files_picture_id"); + + b.Navigation("Account"); + + b.Navigation("Background"); + + b.Navigation("Picture"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany("OutgoingRelationships") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_relationships_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Account.Account", "Related") + .WithMany("IncomingRelationships") + .HasForeignKey("RelatedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_relationships_accounts_related_id"); + + b.Navigation("Account"); + + b.Navigation("Related"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany("Challenges") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_challenges_accounts_account_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Auth.Session", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany("Sessions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_sessions_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Auth.Challenge", "Challenge") + .WithMany() + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id"); + + b.Navigation("Account"); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b => + { + b.HasOne("DysonNetwork.Sphere.Post.Post", "ForwardedPost") + .WithMany() + .HasForeignKey("ForwardedPostId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_posts_posts_forwarded_post_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher") + .WithMany("Posts") + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_posts_publishers_publisher_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", "RepliedPost") + .WithMany() + .HasForeignKey("RepliedPostId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_posts_posts_replied_post_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", "ThreadedPost") + .WithOne() + .HasForeignKey("DysonNetwork.Sphere.Post.Post", "ThreadedPostId") + .HasConstraintName("fk_posts_posts_threaded_post_id"); + + b.Navigation("ForwardedPost"); + + b.Navigation("Publisher"); + + b.Navigation("RepliedPost"); + + b.Navigation("ThreadedPost"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCollection", b => + { + b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher") + .WithMany("Collections") + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_collections_publishers_publisher_id"); + + b.Navigation("Publisher"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostReaction", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_reactions_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", "Post") + .WithMany("Reactions") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_reactions_posts_post_id"); + + b.Navigation("Account"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .HasConstraintName("fk_publishers_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Storage.CloudFile", "Background") + .WithMany() + .HasForeignKey("BackgroundId") + .HasConstraintName("fk_publishers_files_background_id"); + + b.HasOne("DysonNetwork.Sphere.Storage.CloudFile", "Picture") + .WithMany() + .HasForeignKey("PictureId") + .HasConstraintName("fk_publishers_files_picture_id"); + + b.Navigation("Account"); + + b.Navigation("Background"); + + b.Navigation("Picture"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PublisherMember", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_publisher_members_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher") + .WithMany("Members") + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_publisher_members_publishers_publisher_id"); + + b.Navigation("Account"); + + b.Navigation("Publisher"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Storage.CloudFile", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_files_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", null) + .WithMany("Attachments") + .HasForeignKey("PostId") + .HasConstraintName("fk_files_posts_post_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("PostPostCategory", b => + { + b.HasOne("DysonNetwork.Sphere.Post.PostCategory", null) + .WithMany() + .HasForeignKey("CategoriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_category_links_post_categories_categories_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_category_links_posts_posts_id"); + }); + + modelBuilder.Entity("PostPostCollection", b => + { + b.HasOne("DysonNetwork.Sphere.Post.PostCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_collection_links_post_collections_collections_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_collection_links_posts_posts_id"); + }); + + modelBuilder.Entity("PostPostTag", b => + { + b.HasOne("DysonNetwork.Sphere.Post.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_tag_links_posts_posts_id"); + + b.HasOne("DysonNetwork.Sphere.Post.PostTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_tag_links_post_tags_tags_id"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b => + { + b.Navigation("AuthFactors"); + + b.Navigation("Challenges"); + + b.Navigation("Contacts"); + + b.Navigation("IncomingRelationships"); + + b.Navigation("OutgoingRelationships"); + + b.Navigation("Profile") + .IsRequired(); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b => + { + b.Navigation("Attachments"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b => + { + b.Navigation("Collections"); + + b.Navigation("Members"); + + b.Navigation("Posts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Sphere/Migrations/20250427154356_AddNotification.cs b/DysonNetwork.Sphere/Migrations/20250427154356_AddNotification.cs new file mode 100644 index 0000000..40db3bb --- /dev/null +++ b/DysonNetwork.Sphere/Migrations/20250427154356_AddNotification.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace DysonNetwork.Sphere.Migrations +{ + /// <inheritdoc /> + public partial class AddNotification : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn<string>( + name: "picture_id", + table: "publishers", + type: "character varying(128)", + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn<string>( + name: "background_id", + table: "publishers", + type: "character varying(128)", + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn<string>( + name: "id", + table: "files", + type: "character varying(128)", + maxLength: 128, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.AddColumn<Instant>( + name: "expired_at", + table: "files", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AlterColumn<string>( + name: "picture_id", + table: "account_profiles", + type: "character varying(128)", + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.AlterColumn<string>( + name: "background_id", + table: "account_profiles", + type: "character varying(128)", + nullable: true, + oldClrType: typeof(string), + oldType: "text", + oldNullable: true); + + migrationBuilder.CreateTable( + name: "notification_push_subscriptions", + columns: table => new + { + id = table.Column<Guid>(type: "uuid", nullable: false), + device_id = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false), + device_token = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false), + provider = table.Column<int>(type: "integer", nullable: false), + last_used_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true), + account_id = table.Column<long>(type: "bigint", 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_notification_push_subscriptions", x => x.id); + table.ForeignKey( + name: "fk_notification_push_subscriptions_accounts_account_id", + column: x => x.account_id, + principalTable: "accounts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "notifications", + columns: table => new + { + id = table.Column<Guid>(type: "uuid", nullable: false), + title = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true), + subtitle = table.Column<string>(type: "character varying(2048)", maxLength: 2048, nullable: true), + content = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true), + meta = table.Column<Dictionary<string, object>>(type: "jsonb", nullable: true), + priority = table.Column<int>(type: "integer", nullable: false), + viewed_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true), + account_id = table.Column<long>(type: "bigint", 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_notifications", x => x.id); + table.ForeignKey( + name: "fk_notifications_accounts_account_id", + column: x => x.account_id, + principalTable: "accounts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_notification_push_subscriptions_account_id", + table: "notification_push_subscriptions", + column: "account_id"); + + migrationBuilder.CreateIndex( + name: "ix_notification_push_subscriptions_device_id", + table: "notification_push_subscriptions", + column: "device_id", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_notification_push_subscriptions_device_token", + table: "notification_push_subscriptions", + column: "device_token", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_notifications_account_id", + table: "notifications", + column: "account_id"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "notification_push_subscriptions"); + + migrationBuilder.DropTable( + name: "notifications"); + + migrationBuilder.DropColumn( + name: "expired_at", + table: "files"); + + migrationBuilder.AlterColumn<string>( + name: "picture_id", + table: "publishers", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(128)", + oldNullable: true); + + migrationBuilder.AlterColumn<string>( + name: "background_id", + table: "publishers", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(128)", + oldNullable: true); + + migrationBuilder.AlterColumn<string>( + name: "id", + table: "files", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(128)", + oldMaxLength: 128); + + migrationBuilder.AlterColumn<string>( + name: "picture_id", + table: "account_profiles", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(128)", + oldNullable: true); + + migrationBuilder.AlterColumn<string>( + name: "background_id", + table: "account_profiles", + type: "text", + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(128)", + oldNullable: true); + } + } +} diff --git a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs index 64639ef..e58ea83 100644 --- a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs @@ -167,6 +167,125 @@ namespace DysonNetwork.Sphere.Migrations b.ToTable("account_contacts", (string)null); }); + modelBuilder.Entity("DysonNetwork.Sphere.Account.Notification", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<long>("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property<string>("Content") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("content"); + + 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<Dictionary<string, object>>("Meta") + .HasColumnType("jsonb") + .HasColumnName("meta"); + + b.Property<int>("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property<string>("Subtitle") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("subtitle"); + + b.Property<string>("Title") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("title"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property<Instant?>("ViewedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("viewed_at"); + + b.HasKey("Id") + .HasName("pk_notifications"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_notifications_account_id"); + + b.ToTable("notifications", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.NotificationPushSubscription", 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<string>("DeviceId") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("device_id"); + + b.Property<string>("DeviceToken") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("device_token"); + + b.Property<Instant?>("LastUsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at"); + + b.Property<int>("Provider") + .HasColumnType("integer") + .HasColumnName("provider"); + + b.Property<Instant>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_notification_push_subscriptions"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_notification_push_subscriptions_account_id"); + + b.HasIndex("DeviceId") + .IsUnique() + .HasDatabaseName("ix_notification_push_subscriptions_device_id"); + + b.HasIndex("DeviceToken") + .IsUnique() + .HasDatabaseName("ix_notification_push_subscriptions_device_token"); + + b.ToTable("notification_push_subscriptions", (string)null); + }); + modelBuilder.Entity("DysonNetwork.Sphere.Account.Profile", b => { b.Property<long>("Id") @@ -174,7 +293,7 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnName("id"); b.Property<string>("BackgroundId") - .HasColumnType("text") + .HasColumnType("character varying(128)") .HasColumnName("background_id"); b.Property<string>("Bio") @@ -206,7 +325,7 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnName("middle_name"); b.Property<string>("PictureId") - .HasColumnType("text") + .HasColumnType("character varying(128)") .HasColumnName("picture_id"); b.Property<Instant>("UpdatedAt") @@ -692,7 +811,7 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnName("account_id"); b.Property<string>("BackgroundId") - .HasColumnType("text") + .HasColumnType("character varying(128)") .HasColumnName("background_id"); b.Property<string>("Bio") @@ -721,7 +840,7 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnName("nick"); b.Property<string>("PictureId") - .HasColumnType("text") + .HasColumnType("character varying(128)") .HasColumnName("picture_id"); b.Property<int>("PublisherType") @@ -793,7 +912,8 @@ namespace DysonNetwork.Sphere.Migrations modelBuilder.Entity("DysonNetwork.Sphere.Storage.CloudFile", b => { b.Property<string>("Id") - .HasColumnType("text") + .HasMaxLength(128) + .HasColumnType("character varying(128)") .HasColumnName("id"); b.Property<long>("AccountId") @@ -813,6 +933,10 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("character varying(4096)") .HasColumnName("description"); + b.Property<Instant?>("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + b.Property<Dictionary<string, object>>("FileMeta") .HasColumnType("jsonb") .HasColumnName("file_meta"); @@ -955,6 +1079,30 @@ namespace DysonNetwork.Sphere.Migrations b.Navigation("Account"); }); + modelBuilder.Entity("DysonNetwork.Sphere.Account.Notification", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notifications_accounts_account_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.NotificationPushSubscription", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notification_push_subscriptions_accounts_account_id"); + + b.Navigation("Account"); + }); + modelBuilder.Entity("DysonNetwork.Sphere.Account.Profile", b => { b.HasOne("DysonNetwork.Sphere.Storage.CloudFile", "Background") diff --git a/DysonNetwork.Sphere/Post/PostController.cs b/DysonNetwork.Sphere/Post/PostController.cs index cd12eb1..5f1ac9d 100644 --- a/DysonNetwork.Sphere/Post/PostController.cs +++ b/DysonNetwork.Sphere/Post/PostController.cs @@ -27,6 +27,7 @@ public class PostController(AppDatabase db, PostService ps, IEnforcer enforcer) .Include(e => e.Attachments) .Include(e => e.Categories) .Include(e => e.Tags) + .Where(e => e.RepliedPostId == null) .FilterWithVisibility(currentUser, isListing: true) .OrderByDescending(e => e.PublishedAt ?? e.CreatedAt) .Skip(offset) @@ -110,6 +111,8 @@ public class PostController(AppDatabase db, PostService ps, IEnforcer enforcer) [MaxLength(32)] public List<string>? Attachments { get; set; } public Dictionary<string, object>? Meta { get; set; } public Instant? PublishedAt { get; set; } + public long? RepliedPostId { get; set; } + public long? ForwardedPostId { get; set; } } [HttpPost] @@ -155,6 +158,22 @@ public class PostController(AppDatabase db, PostService ps, IEnforcer enforcer) Publisher = publisher, }; + if (request.RepliedPostId is not null) + { + var repliedPost = await db.Posts.FindAsync(request.RepliedPostId.Value); + if (repliedPost is null) return BadRequest("Post replying to was not found."); + post.RepliedPost = repliedPost; + post.RepliedPostId = repliedPost.Id; + } + + if (request.ForwardedPostId is not null) + { + var forwardedPost = await db.Posts.FindAsync(request.ForwardedPostId.Value); + if (forwardedPost is null) return BadRequest("Forwarded post was not found."); + post.ForwardedPost = forwardedPost; + post.ForwardedPostId = forwardedPost.Id; + } + try { post = await ps.PostAsync( @@ -226,6 +245,7 @@ public class PostController(AppDatabase db, PostService ps, IEnforcer enforcer) var post = await db.Posts .Where(e => e.Id == id) + .Include(e => e.Publisher) .Include(e => e.Attachments) .FirstOrDefaultAsync(); if (post is null) return NotFound(); diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs index 7a5d9cf..51c1a82 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -130,6 +130,7 @@ builder.Services.AddSwaggerGen(options => builder.Services.AddOpenApi(); builder.Services.AddScoped<AccountService>(); +builder.Services.AddScoped<NotificationService>(); builder.Services.AddScoped<AuthService>(); builder.Services.AddScoped<FileService>(); builder.Services.AddScoped<PublisherService>(); diff --git a/DysonNetwork.Sphere/Storage/CloudFile.cs b/DysonNetwork.Sphere/Storage/CloudFile.cs index adcefd1..ebe5dfb 100644 --- a/DysonNetwork.Sphere/Storage/CloudFile.cs +++ b/DysonNetwork.Sphere/Storage/CloudFile.cs @@ -5,7 +5,7 @@ using NodaTime; namespace DysonNetwork.Sphere.Storage; -public class RemoteStorageConfig +public abstract class RemoteStorageConfig { public string Id { get; set; } = string.Empty; public string Label { get; set; } = string.Empty; @@ -22,7 +22,7 @@ public class RemoteStorageConfig public class CloudFile : ModelBase { - public string Id { get; set; } = Guid.NewGuid().ToString(); + [MaxLength(128)] public string Id { get; set; } = Guid.NewGuid().ToString(); [MaxLength(1024)] public string Name { get; set; } = string.Empty; [MaxLength(4096)] public string? Description { get; set; } [Column(TypeName = "jsonb")] public Dictionary<string, object>? FileMeta { get; set; } = null!; @@ -32,6 +32,7 @@ public class CloudFile : ModelBase [MaxLength(256)] public string? Hash { get; set; } public long Size { get; set; } public Instant? UploadedAt { get; set; } + public Instant? ExpiredAt { get; set; } [MaxLength(128)] public string? UploadedTo { get; set; } // Metrics diff --git a/DysonNetwork.Sphere/Storage/FileService.cs b/DysonNetwork.Sphere/Storage/FileService.cs index b0a96dd..7096846 100644 --- a/DysonNetwork.Sphere/Storage/FileService.cs +++ b/DysonNetwork.Sphere/Storage/FileService.cs @@ -249,9 +249,12 @@ public class CloudFileUnusedRecyclingJob(AppDatabase db, FileService fs, ILogger logger.LogInformation("Deleting unused cloud files..."); var cutoff = SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(1); + var now = SystemClock.Instance.GetCurrentInstant(); var files = db.Files - .Where(f => f.UsedCount == 0) - .Where(f => f.CreatedAt < cutoff) + .Where(f => + (f.ExpiredAt == null && f.UsedCount == 0 && f.CreatedAt < cutoff) || + (f.ExpiredAt != null && f.ExpiredAt >= now) + ) .ToList(); logger.LogInformation($"Deleting {files.Count} unused cloud files..."); diff --git a/DysonNetwork.Sphere/appsettings.json b/DysonNetwork.Sphere/appsettings.json index b4eaf04..c3c393c 100644 --- a/DysonNetwork.Sphere/appsettings.json +++ b/DysonNetwork.Sphere/appsettings.json @@ -49,5 +49,17 @@ "Provider": "recaptcha", "ApiKey": "6LfIzSArAAAAAN413MtycDcPlKa636knBSAhbzj-", "ApiSecret": "" + }, + "Notifications": { + "Push": { + "Production": true, + "Google": "path/to/config.json", + "Apple": { + "PrivateKey": "path/to/cert.p8", + "PrivateKeyId": "", + "TeamId": "", + "BundleIdentifier": "" + } + } } } diff --git a/DysonNetwork.sln.DotSettings.user b/DysonNetwork.sln.DotSettings.user index 706b819..8a83c30 100644 --- a/DysonNetwork.sln.DotSettings.user +++ b/DysonNetwork.sln.DotSettings.user @@ -1,4 +1,5 @@ <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> + <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AApnSender_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F6aadc2cf048f477d8636fb2def7b73648200_003Fc5_003F2a1973a9_003FApnSender_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthenticationMiddleware_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fe49de78932194d52a02b07486c6d023a24600_003F2f_003F7ab1cc57_003FAuthenticationMiddleware_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AAuthorizationAppBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F2ff26593f91746d7a53418a46dc419d1f200_003F4b_003F56550da2_003FAuthorizationAppBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String> <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ABucketArgs_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fd515fb889657fcdcace3fed90735057b458ff9e0bb60bded7c8fe8b3a4673c_003FBucketArgs_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>