diff --git a/.editorconfig b/.editorconfig
index e6b41f9..22061dc 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -7,7 +7,7 @@ resharper_not_accessed_positional_property_local_highlighting = none
# Microsoft .NET properties
csharp_new_line_before_members_in_object_initializers = false
-csharp_preferred_modifier_order = public, internal, protected, private, file, new, required, abstract, virtual, sealed, static, override, extern, unsafe, volatile, async, readonly:suggestion
+csharp_preferred_modifier_order = public, internal, protected, private, file, new, virtual, override, required, abstract, sealed, static, extern, unsafe, volatile, async, readonly:suggestion
# ReSharper properties
resharper_align_multiline_binary_expressions_chain = false
diff --git a/Foxnouns.Backend/Controllers/MetaController.cs b/Foxnouns.Backend/Controllers/MetaController.cs
index 1f00a7a..0166e86 100644
--- a/Foxnouns.Backend/Controllers/MetaController.cs
+++ b/Foxnouns.Backend/Controllers/MetaController.cs
@@ -13,20 +13,23 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
using System.Text.RegularExpressions;
+using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
+using Foxnouns.Backend.Services.Caching;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc;
namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/meta")]
-public partial class MetaController(Config config) : ApiControllerBase
+public partial class MetaController(Config config, NoticeCacheService noticeCache)
+ : ApiControllerBase
{
private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc";
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
- public IActionResult GetMeta() =>
+ public async Task GetMeta(CancellationToken ct = default) =>
Ok(
new MetaResponse(
Repository,
@@ -45,10 +48,14 @@ public partial class MetaController(Config config) : ApiControllerBase
ValidationUtils.MaxCustomPreferences,
AuthUtils.MaxAuthMethodsPerType,
FlagsController.MaxFlagCount
- )
+ ),
+ Notice: NoticeResponse(await noticeCache.GetAsync(ct))
)
);
+ private static MetaNoticeResponse? NoticeResponse(Notice? notice) =>
+ notice == null ? null : new MetaNoticeResponse(notice.Id, notice.Message);
+
[HttpGet("page/{page}")]
public async Task GetStaticPageAsync(string page, CancellationToken ct = default)
{
@@ -71,7 +78,7 @@ public partial class MetaController(Config config) : ApiControllerBase
[HttpGet("/api/v2/coffee")]
public IActionResult BrewCoffee() =>
- Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
+ StatusCode(StatusCodes.Status418ImATeapot, "Sorry, I'm a teapot!");
[GeneratedRegex(@"^[a-z\-_]+$")]
private static partial Regex PageRegex();
diff --git a/Foxnouns.Backend/Controllers/Moderation/NoticesController.cs b/Foxnouns.Backend/Controllers/Moderation/NoticesController.cs
new file mode 100644
index 0000000..3d2d6bb
--- /dev/null
+++ b/Foxnouns.Backend/Controllers/Moderation/NoticesController.cs
@@ -0,0 +1,77 @@
+using Foxnouns.Backend.Database;
+using Foxnouns.Backend.Database.Models;
+using Foxnouns.Backend.Dto;
+using Foxnouns.Backend.Middleware;
+using Foxnouns.Backend.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using NodaTime;
+
+namespace Foxnouns.Backend.Controllers.Moderation;
+
+[Route("/api/v2/notices")]
+[Authorize("user.moderation")]
+[Limit(RequireModerator = true)]
+public class NoticesController(
+ DatabaseContext db,
+ UserRendererService userRenderer,
+ ISnowflakeGenerator snowflakeGenerator,
+ IClock clock
+) : ApiControllerBase
+{
+ [HttpGet]
+ public async Task GetNoticesAsync(CancellationToken ct = default)
+ {
+ List notices = await db
+ .Notices.Include(n => n.Author)
+ .OrderByDescending(n => n.Id)
+ .ToListAsync(ct);
+ return Ok(notices.Select(RenderNotice));
+ }
+
+ [HttpPost]
+ public async Task CreateNoticeAsync(CreateNoticeRequest req)
+ {
+ Instant now = clock.GetCurrentInstant();
+ if (req.StartTime < now)
+ {
+ throw new ApiError.BadRequest(
+ "Start time cannot be in the past",
+ "start_time",
+ req.StartTime
+ );
+ }
+
+ if (req.EndTime < now)
+ {
+ throw new ApiError.BadRequest(
+ "End time cannot be in the past",
+ "end_time",
+ req.EndTime
+ );
+ }
+
+ var notice = new Notice
+ {
+ Id = snowflakeGenerator.GenerateSnowflake(),
+ Message = req.Message,
+ StartTime = req.StartTime ?? clock.GetCurrentInstant(),
+ EndTime = req.EndTime,
+ Author = CurrentUser!,
+ };
+
+ db.Add(notice);
+ await db.SaveChangesAsync();
+
+ return Ok(RenderNotice(notice));
+ }
+
+ private NoticeResponse RenderNotice(Notice notice) =>
+ new(
+ notice.Id,
+ notice.Message,
+ notice.StartTime,
+ notice.EndTime,
+ userRenderer.RenderPartialUser(notice.Author)
+ );
+}
diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs
index 31aa98a..ed9a48f 100644
--- a/Foxnouns.Backend/Controllers/UsersController.cs
+++ b/Foxnouns.Backend/Controllers/UsersController.cs
@@ -281,6 +281,8 @@ public class UsersController(
if (req.HasProperty(nameof(req.DarkMode)))
user.Settings.DarkMode = req.DarkMode;
+ if (req.HasProperty(nameof(req.LastReadNotice)))
+ user.Settings.LastReadNotice = req.LastReadNotice;
user.LastActive = clock.GetCurrentInstant();
db.Update(user);
diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs
index c9120f3..2bbcbc7 100644
--- a/Foxnouns.Backend/Database/DatabaseContext.cs
+++ b/Foxnouns.Backend/Database/DatabaseContext.cs
@@ -73,6 +73,7 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
public DbSet Reports { get; init; } = null!;
public DbSet AuditLog { get; init; } = null!;
public DbSet Notifications { get; init; } = null!;
+ public DbSet Notices { get; init; } = null!;
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
diff --git a/Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.Designer.cs b/Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.Designer.cs
new file mode 100644
index 0000000..d2df141
--- /dev/null
+++ b/Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.Designer.cs
@@ -0,0 +1,915 @@
+//
+using System.Collections.Generic;
+using Foxnouns.Backend.Database;
+using Foxnouns.Backend.Database.Models;
+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 Foxnouns.Backend.Database.Migrations
+{
+ [DbContext(typeof(DatabaseContext))]
+ [Migration("20250329131053_AddNotices")]
+ partial class AddNotices
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("ClientId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("client_id");
+
+ b.Property("ClientSecret")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("client_secret");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.PrimitiveCollection("RedirectUris")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("redirect_uris");
+
+ b.PrimitiveCollection("Scopes")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("scopes");
+
+ b.HasKey("Id")
+ .HasName("pk_applications");
+
+ b.ToTable("applications", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.PrimitiveCollection("ClearedFields")
+ .HasColumnType("text[]")
+ .HasColumnName("cleared_fields");
+
+ b.Property("ModeratorId")
+ .HasColumnType("bigint")
+ .HasColumnName("moderator_id");
+
+ b.Property("ModeratorUsername")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("moderator_username");
+
+ b.Property("Reason")
+ .HasColumnType("text")
+ .HasColumnName("reason");
+
+ b.Property("ReportId")
+ .HasColumnType("bigint")
+ .HasColumnName("report_id");
+
+ b.Property("TargetMemberId")
+ .HasColumnType("bigint")
+ .HasColumnName("target_member_id");
+
+ b.Property("TargetMemberName")
+ .HasColumnType("text")
+ .HasColumnName("target_member_name");
+
+ b.Property("TargetUserId")
+ .HasColumnType("bigint")
+ .HasColumnName("target_user_id");
+
+ b.Property("TargetUsername")
+ .HasColumnType("text")
+ .HasColumnName("target_username");
+
+ b.Property("Type")
+ .HasColumnType("integer")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_audit_log");
+
+ b.HasIndex("ReportId")
+ .IsUnique()
+ .HasDatabaseName("ix_audit_log_report_id");
+
+ b.ToTable("audit_log", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("AuthType")
+ .HasColumnType("integer")
+ .HasColumnName("auth_type");
+
+ b.Property("FediverseApplicationId")
+ .HasColumnType("bigint")
+ .HasColumnName("fediverse_application_id");
+
+ b.Property("RemoteId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("remote_id");
+
+ b.Property("RemoteUsername")
+ .HasColumnType("text")
+ .HasColumnName("remote_username");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_auth_methods");
+
+ b.HasIndex("FediverseApplicationId")
+ .HasDatabaseName("ix_auth_methods_fediverse_application_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_auth_methods_user_id");
+
+ b.HasIndex("AuthType", "RemoteId")
+ .IsUnique()
+ .HasDatabaseName("ix_auth_methods_auth_type_remote_id")
+ .HasFilter("fediverse_application_id IS NULL");
+
+ b.HasIndex("AuthType", "RemoteId", "FediverseApplicationId")
+ .IsUnique()
+ .HasDatabaseName("ix_auth_methods_auth_type_remote_id_fediverse_application_id")
+ .HasFilter("fediverse_application_id IS NOT NULL");
+
+ b.ToTable("auth_methods", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("Filename")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("filename");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_data_exports");
+
+ b.HasIndex("Filename")
+ .IsUnique()
+ .HasDatabaseName("ix_data_exports_filename");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_data_exports_user_id");
+
+ b.ToTable("data_exports", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("ClientId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("client_id");
+
+ b.Property("ClientSecret")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("client_secret");
+
+ b.Property("Domain")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("domain");
+
+ b.Property("ForceRefresh")
+ .HasColumnType("boolean")
+ .HasColumnName("force_refresh");
+
+ b.Property("InstanceType")
+ .HasColumnType("integer")
+ .HasColumnName("instance_type");
+
+ b.HasKey("Id")
+ .HasName("pk_fediverse_applications");
+
+ b.ToTable("fediverse_applications", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("Avatar")
+ .HasColumnType("text")
+ .HasColumnName("avatar");
+
+ b.Property("Bio")
+ .HasColumnType("text")
+ .HasColumnName("bio");
+
+ b.Property("DisplayName")
+ .HasColumnType("text")
+ .HasColumnName("display_name");
+
+ b.Property>("Fields")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("fields");
+
+ b.Property("LegacyId")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasColumnName("legacy_id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.PrimitiveCollection("Links")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("links");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property>("Names")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("names");
+
+ b.Property>("Pronouns")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("pronouns");
+
+ b.Property("Sid")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasColumnName("sid")
+ .HasDefaultValueSql("find_free_member_sid()");
+
+ b.Property("Unlisted")
+ .HasColumnType("boolean")
+ .HasColumnName("unlisted");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_members");
+
+ b.HasIndex("LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_members_legacy_id");
+
+ b.HasIndex("Sid")
+ .IsUnique()
+ .HasDatabaseName("ix_members_sid");
+
+ b.HasIndex("UserId", "Name")
+ .IsUnique()
+ .HasDatabaseName("ix_members_user_id_name");
+
+ b.ToTable("members", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("MemberId")
+ .HasColumnType("bigint")
+ .HasColumnName("member_id");
+
+ b.Property("PrideFlagId")
+ .HasColumnType("bigint")
+ .HasColumnName("pride_flag_id");
+
+ b.HasKey("Id")
+ .HasName("pk_member_flags");
+
+ b.HasIndex("MemberId")
+ .HasDatabaseName("ix_member_flags_member_id");
+
+ b.HasIndex("PrideFlagId")
+ .HasDatabaseName("ix_member_flags_pride_flag_id");
+
+ b.ToTable("member_flags", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("AuthorId")
+ .HasColumnType("bigint")
+ .HasColumnName("author_id");
+
+ b.Property("EndTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("end_time");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("message");
+
+ b.Property("StartTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start_time");
+
+ b.HasKey("Id")
+ .HasName("pk_notices");
+
+ b.HasIndex("AuthorId")
+ .HasDatabaseName("ix_notices_author_id");
+
+ b.ToTable("notices", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("AcknowledgedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("acknowledged_at");
+
+ b.Property("LocalizationKey")
+ .HasColumnType("text")
+ .HasColumnName("localization_key");
+
+ b.Property>("LocalizationParams")
+ .IsRequired()
+ .HasColumnType("hstore")
+ .HasColumnName("localization_params");
+
+ b.Property("Message")
+ .HasColumnType("text")
+ .HasColumnName("message");
+
+ b.Property("TargetId")
+ .HasColumnType("bigint")
+ .HasColumnName("target_id");
+
+ b.Property("Type")
+ .HasColumnType("integer")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_notifications");
+
+ b.HasIndex("TargetId")
+ .HasDatabaseName("ix_notifications_target_id");
+
+ b.ToTable("notifications", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Hash")
+ .HasColumnType("text")
+ .HasColumnName("hash");
+
+ b.Property("LegacyId")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasColumnName("legacy_id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_pride_flags");
+
+ b.HasIndex("LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_pride_flags_legacy_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_pride_flags_user_id");
+
+ b.ToTable("pride_flags", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("Context")
+ .HasColumnType("text")
+ .HasColumnName("context");
+
+ b.Property("Reason")
+ .HasColumnType("integer")
+ .HasColumnName("reason");
+
+ b.Property("ReporterId")
+ .HasColumnType("bigint")
+ .HasColumnName("reporter_id");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.Property("TargetMemberId")
+ .HasColumnType("bigint")
+ .HasColumnName("target_member_id");
+
+ b.Property("TargetSnapshot")
+ .HasColumnType("text")
+ .HasColumnName("target_snapshot");
+
+ b.Property("TargetType")
+ .HasColumnType("integer")
+ .HasColumnName("target_type");
+
+ b.Property("TargetUserId")
+ .HasColumnType("bigint")
+ .HasColumnName("target_user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_reports");
+
+ b.HasIndex("ReporterId")
+ .HasDatabaseName("ix_reports_reporter_id");
+
+ b.HasIndex("TargetMemberId")
+ .HasDatabaseName("ix_reports_target_member_id");
+
+ b.HasIndex("TargetUserId")
+ .HasDatabaseName("ix_reports_target_user_id");
+
+ b.ToTable("reports", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("ApplicationId")
+ .HasColumnType("bigint")
+ .HasColumnName("application_id");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("Hash")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("hash");
+
+ b.Property("ManuallyExpired")
+ .HasColumnType("boolean")
+ .HasColumnName("manually_expired");
+
+ b.PrimitiveCollection("Scopes")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("scopes");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tokens");
+
+ b.HasIndex("ApplicationId")
+ .HasDatabaseName("ix_tokens_application_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_tokens_user_id");
+
+ b.ToTable("tokens", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("Avatar")
+ .HasColumnType("text")
+ .HasColumnName("avatar");
+
+ b.Property("Bio")
+ .HasColumnType("text")
+ .HasColumnName("bio");
+
+ b.Property>("CustomPreferences")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("custom_preferences");
+
+ b.Property("Deleted")
+ .HasColumnType("boolean")
+ .HasColumnName("deleted");
+
+ b.Property("DeletedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at");
+
+ b.Property("DeletedBy")
+ .HasColumnType("bigint")
+ .HasColumnName("deleted_by");
+
+ b.Property("DisplayName")
+ .HasColumnType("text")
+ .HasColumnName("display_name");
+
+ b.Property>("Fields")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("fields");
+
+ b.Property("LastActive")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_active");
+
+ b.Property("LastSidReroll")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_sid_reroll");
+
+ b.Property("LegacyId")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasColumnName("legacy_id")
+ .HasDefaultValueSql("gen_random_uuid()");
+
+ b.PrimitiveCollection("Links")
+ .IsRequired()
+ .HasColumnType("text[]")
+ .HasColumnName("links");
+
+ b.Property("ListHidden")
+ .HasColumnType("boolean")
+ .HasColumnName("list_hidden");
+
+ b.Property("MemberTitle")
+ .HasColumnType("text")
+ .HasColumnName("member_title");
+
+ b.Property>("Names")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("names");
+
+ b.Property("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property>("Pronouns")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("pronouns");
+
+ b.Property("Role")
+ .HasColumnType("integer")
+ .HasColumnName("role");
+
+ b.Property("Settings")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("settings");
+
+ b.Property("Sid")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasColumnName("sid")
+ .HasDefaultValueSql("find_free_user_sid()");
+
+ b.Property("Timezone")
+ .HasColumnType("text")
+ .HasColumnName("timezone");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.HasIndex("LegacyId")
+ .IsUnique()
+ .HasDatabaseName("ix_users_legacy_id");
+
+ b.HasIndex("Sid")
+ .IsUnique()
+ .HasDatabaseName("ix_users_sid");
+
+ b.HasIndex("Username")
+ .IsUnique()
+ .HasDatabaseName("ix_users_username");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("PrideFlagId")
+ .HasColumnType("bigint")
+ .HasColumnName("pride_flag_id");
+
+ b.Property("UserId")
+ .HasColumnType("bigint")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_user_flags");
+
+ b.HasIndex("PrideFlagId")
+ .HasDatabaseName("ix_user_flags_pride_flag_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_user_flags_user_id");
+
+ b.ToTable("user_flags", (string)null);
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report")
+ .WithOne("AuditLogEntry")
+ .HasForeignKey("Foxnouns.Backend.Database.Models.AuditLogEntry", "ReportId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("fk_audit_log_reports_report_id");
+
+ b.Navigation("Report");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication")
+ .WithMany()
+ .HasForeignKey("FediverseApplicationId")
+ .HasConstraintName("fk_auth_methods_fediverse_applications_fediverse_application_id");
+
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
+ .WithMany("AuthMethods")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_auth_methods_users_user_id");
+
+ b.Navigation("FediverseApplication");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
+ .WithMany("DataExports")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_data_exports_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
+ .WithMany("Members")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_members_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.Member", null)
+ .WithMany("ProfileFlags")
+ .HasForeignKey("MemberId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_member_flags_members_member_id");
+
+ b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag")
+ .WithMany()
+ .HasForeignKey("PrideFlagId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_member_flags_pride_flags_pride_flag_id");
+
+ b.Navigation("PrideFlag");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "Author")
+ .WithMany()
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_notices_users_author_id");
+
+ b.Navigation("Author");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "Target")
+ .WithMany()
+ .HasForeignKey("TargetId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_notifications_users_target_id");
+
+ b.Navigation("Target");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.User", null)
+ .WithMany("Flags")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_pride_flags_users_user_id");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "Reporter")
+ .WithMany()
+ .HasForeignKey("ReporterId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_reports_users_reporter_id");
+
+ b.HasOne("Foxnouns.Backend.Database.Models.Member", "TargetMember")
+ .WithMany()
+ .HasForeignKey("TargetMemberId")
+ .HasConstraintName("fk_reports_members_target_member_id");
+
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "TargetUser")
+ .WithMany()
+ .HasForeignKey("TargetUserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_reports_users_target_user_id");
+
+ b.Navigation("Reporter");
+
+ b.Navigation("TargetMember");
+
+ b.Navigation("TargetUser");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
+ .WithMany()
+ .HasForeignKey("ApplicationId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tokens_applications_application_id");
+
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tokens_users_user_id");
+
+ b.Navigation("Application");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag")
+ .WithMany()
+ .HasForeignKey("PrideFlagId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_flags_pride_flags_pride_flag_id");
+
+ b.HasOne("Foxnouns.Backend.Database.Models.User", null)
+ .WithMany("ProfileFlags")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_user_flags_users_user_id");
+
+ b.Navigation("PrideFlag");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
+ {
+ b.Navigation("ProfileFlags");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
+ {
+ b.Navigation("AuditLogEntry");
+ });
+
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
+ {
+ b.Navigation("AuthMethods");
+
+ b.Navigation("DataExports");
+
+ b.Navigation("Flags");
+
+ b.Navigation("Members");
+
+ b.Navigation("ProfileFlags");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.cs b/Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.cs
new file mode 100644
index 0000000..24c5166
--- /dev/null
+++ b/Foxnouns.Backend/Database/Migrations/20250329131053_AddNotices.cs
@@ -0,0 +1,56 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+using NodaTime;
+
+#nullable disable
+
+namespace Foxnouns.Backend.Database.Migrations
+{
+ ///
+ public partial class AddNotices : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "notices",
+ columns: table => new
+ {
+ id = table.Column(type: "bigint", nullable: false),
+ message = table.Column(type: "text", nullable: false),
+ start_time = table.Column(
+ type: "timestamp with time zone",
+ nullable: false
+ ),
+ end_time = table.Column(
+ type: "timestamp with time zone",
+ nullable: false
+ ),
+ author_id = table.Column(type: "bigint", nullable: false),
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_notices", x => x.id);
+ table.ForeignKey(
+ name: "fk_notices_users_author_id",
+ column: x => x.author_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade
+ );
+ }
+ );
+
+ migrationBuilder.CreateIndex(
+ name: "ix_notices_author_id",
+ table: "notices",
+ column: "author_id"
+ );
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(name: "notices");
+ }
+ }
+}
diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs
index 922a599..70b035d 100644
--- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs
+++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs
@@ -343,6 +343,38 @@ namespace Foxnouns.Backend.Database.Migrations
b.ToTable("member_flags", (string)null);
});
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("bigint")
+ .HasColumnName("id");
+
+ b.Property("AuthorId")
+ .HasColumnType("bigint")
+ .HasColumnName("author_id");
+
+ b.Property("EndTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("end_time");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("message");
+
+ b.Property("StartTime")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start_time");
+
+ b.HasKey("Id")
+ .HasName("pk_notices");
+
+ b.HasIndex("AuthorId")
+ .HasDatabaseName("ix_notices_author_id");
+
+ b.ToTable("notices", (string)null);
+ });
+
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
{
b.Property("Id")
@@ -750,6 +782,18 @@ namespace Foxnouns.Backend.Database.Migrations
b.Navigation("PrideFlag");
});
+ modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b =>
+ {
+ b.HasOne("Foxnouns.Backend.Database.Models.User", "Author")
+ .WithMany()
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_notices_users_author_id");
+
+ b.Navigation("Author");
+ });
+
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Target")
diff --git a/Foxnouns.Backend/Database/Models/Notice.cs b/Foxnouns.Backend/Database/Models/Notice.cs
new file mode 100644
index 0000000..c3e6f0d
--- /dev/null
+++ b/Foxnouns.Backend/Database/Models/Notice.cs
@@ -0,0 +1,13 @@
+using NodaTime;
+
+namespace Foxnouns.Backend.Database.Models;
+
+public class Notice : BaseModel
+{
+ public required string Message { get; set; }
+ public required Instant StartTime { get; set; }
+ public required Instant EndTime { get; set; }
+
+ public Snowflake AuthorId { get; init; }
+ public User Author { get; init; } = null!;
+}
diff --git a/Foxnouns.Backend/Database/Models/User.cs b/Foxnouns.Backend/Database/Models/User.cs
index 3ad7ae3..0e6eb43 100644
--- a/Foxnouns.Backend/Database/Models/User.cs
+++ b/Foxnouns.Backend/Database/Models/User.cs
@@ -95,4 +95,5 @@ public enum PreferenceSize
public class UserSettings
{
public bool? DarkMode { get; set; }
+ public Snowflake? LastReadNotice { get; set; }
}
diff --git a/Foxnouns.Backend/Dto/Meta.cs b/Foxnouns.Backend/Dto/Meta.cs
index 0ff6e80..168327a 100644
--- a/Foxnouns.Backend/Dto/Meta.cs
+++ b/Foxnouns.Backend/Dto/Meta.cs
@@ -14,6 +14,8 @@
// along with this program. If not, see .
// ReSharper disable NotAccessedPositionalProperty.Global
+using Foxnouns.Backend.Database;
+
namespace Foxnouns.Backend.Dto;
public record MetaResponse(
@@ -22,9 +24,12 @@ public record MetaResponse(
string Hash,
int Members,
UserInfoResponse Users,
- LimitsResponse Limits
+ LimitsResponse Limits,
+ MetaNoticeResponse? Notice
);
+public record MetaNoticeResponse(Snowflake Id, string Message);
+
public record UserInfoResponse(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay);
public record LimitsResponse(
diff --git a/Foxnouns.Backend/Dto/Moderation.cs b/Foxnouns.Backend/Dto/Moderation.cs
index 26fd0aa..bcc7e8e 100644
--- a/Foxnouns.Backend/Dto/Moderation.cs
+++ b/Foxnouns.Backend/Dto/Moderation.cs
@@ -122,3 +122,13 @@ public record QueryUserResponse(
);
public record QuerySensitiveUserDataRequest(string Reason);
+
+public record NoticeResponse(
+ Snowflake Id,
+ string Message,
+ Instant StartTime,
+ Instant EndTime,
+ PartialUser Author
+);
+
+public record CreateNoticeRequest(string Message, Instant? StartTime, Instant EndTime);
diff --git a/Foxnouns.Backend/Dto/User.cs b/Foxnouns.Backend/Dto/User.cs
index 83121d1..2ae38f1 100644
--- a/Foxnouns.Backend/Dto/User.cs
+++ b/Foxnouns.Backend/Dto/User.cs
@@ -80,6 +80,7 @@ public record PartialUser(
public class UpdateUserSettingsRequest : PatchRequest
{
public bool? DarkMode { get; init; }
+ public Snowflake? LastReadNotice { get; init; }
}
public class CustomPreferenceUpdateRequest
diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs
index 567ae02..c3efda6 100644
--- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs
+++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs
@@ -15,10 +15,12 @@
using Coravel;
using Coravel.Queuing.Interfaces;
using Foxnouns.Backend.Database;
+using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
+using Foxnouns.Backend.Services.Caching;
using Foxnouns.Backend.Services.V1;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Http.Resilience;
@@ -162,6 +164,7 @@ public static class WebApplicationExtensions
.AddScoped()
.AddTransient()
.AddTransient()
+ .AddSingleton()
// Background services
.AddHostedService()
// Transient jobs
diff --git a/Foxnouns.Backend/Services/Auth/AuthService.cs b/Foxnouns.Backend/Services/Auth/AuthService.cs
index 80d05ac..e3e3edb 100644
--- a/Foxnouns.Backend/Services/Auth/AuthService.cs
+++ b/Foxnouns.Backend/Services/Auth/AuthService.cs
@@ -253,14 +253,14 @@ public class AuthService(
{
AssertValidAuthType(authType, app);
- // This is already checked when
+ // This is already checked when generating an add account state, but we check it here too just in case.
int currentCount = await db
.AuthMethods.Where(m => m.UserId == userId && m.AuthType == authType)
.CountAsync(ct);
if (currentCount >= AuthUtils.MaxAuthMethodsPerType)
{
throw new ApiError.BadRequest(
- "Too many linked accounts of this type, maximum of 3 per account."
+ $"Too many linked accounts of this type, maximum of {AuthUtils.MaxAuthMethodsPerType} per account."
);
}
diff --git a/Foxnouns.Backend/Services/Caching/NoticeCacheService.cs b/Foxnouns.Backend/Services/Caching/NoticeCacheService.cs
new file mode 100644
index 0000000..2a0b1f9
--- /dev/null
+++ b/Foxnouns.Backend/Services/Caching/NoticeCacheService.cs
@@ -0,0 +1,39 @@
+// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+using Foxnouns.Backend.Database;
+using Foxnouns.Backend.Database.Models;
+using Microsoft.EntityFrameworkCore;
+using NodaTime;
+
+namespace Foxnouns.Backend.Services.Caching;
+
+public class NoticeCacheService(IServiceProvider serviceProvider, IClock clock, ILogger logger)
+ : SingletonCacheService(serviceProvider, clock, logger)
+{
+ public override Duration MaxAge { get; init; } = Duration.FromMinutes(5);
+
+ public override Func<
+ DatabaseContext,
+ CancellationToken,
+ Task
+ > FetchFunc { get; init; } =
+ async (db, ct) =>
+ await db
+ .Notices.Where(n =>
+ n.StartTime < clock.GetCurrentInstant() && n.EndTime > clock.GetCurrentInstant()
+ )
+ .OrderByDescending(n => n.Id)
+ .FirstOrDefaultAsync(ct);
+}
diff --git a/Foxnouns.Backend/Services/Caching/SingletonCacheService.cs b/Foxnouns.Backend/Services/Caching/SingletonCacheService.cs
new file mode 100644
index 0000000..87b19a7
--- /dev/null
+++ b/Foxnouns.Backend/Services/Caching/SingletonCacheService.cs
@@ -0,0 +1,63 @@
+// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+using Foxnouns.Backend.Database;
+using NodaTime;
+
+namespace Foxnouns.Backend.Services.Caching;
+
+public abstract class SingletonCacheService(
+ IServiceProvider serviceProvider,
+ IClock clock,
+ ILogger logger
+)
+ where T : class
+{
+ private T? _item;
+ private Instant _lastUpdated = Instant.MinValue;
+ private readonly SemaphoreSlim _semaphore = new(1, 1);
+ private readonly ILogger _logger = logger.ForContext>();
+
+ public virtual Duration MaxAge { get; init; } = Duration.FromMinutes(5);
+
+ public virtual Func> FetchFunc { get; init; } =
+ (_, __) => Task.FromResult(null);
+
+ public async Task GetAsync(CancellationToken ct = default)
+ {
+ await _semaphore.WaitAsync(ct);
+ try
+ {
+ if (_lastUpdated > clock.GetCurrentInstant() - MaxAge)
+ {
+ return _item;
+ }
+
+ _logger.Debug("Cached item of type {Type} is expired, fetching it.", typeof(T));
+
+ await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope();
+ await using DatabaseContext db =
+ scope.ServiceProvider.GetRequiredService();
+
+ T? item = await FetchFunc(db, ct);
+ _item = item;
+ _lastUpdated = clock.GetCurrentInstant();
+ return item;
+ }
+ finally
+ {
+ _semaphore.Release();
+ }
+ }
+}
diff --git a/Foxnouns.Frontend/src/lib/api/models/meta.ts b/Foxnouns.Frontend/src/lib/api/models/meta.ts
index 56f31c9..28ea494 100644
--- a/Foxnouns.Frontend/src/lib/api/models/meta.ts
+++ b/Foxnouns.Frontend/src/lib/api/models/meta.ts
@@ -10,6 +10,7 @@ export type Meta = {
};
members: number;
limits: Limits;
+ notice: { id: string; message: string } | null;
};
export type Limits = {
diff --git a/Foxnouns.Frontend/src/lib/api/models/user.ts b/Foxnouns.Frontend/src/lib/api/models/user.ts
index 0610b9c..be9d961 100644
--- a/Foxnouns.Frontend/src/lib/api/models/user.ts
+++ b/Foxnouns.Frontend/src/lib/api/models/user.ts
@@ -41,6 +41,7 @@ export type UserWithHiddenFields = User & {
export type UserSettings = {
dark_mode: boolean | null;
+ last_read_notice: string | null;
};
export type PartialMember = {