From b687f1599aa16c0fb7db235534e9453210db415a Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Sat, 13 Jun 2026 01:47:01 +0530 Subject: [PATCH 1/5] perf(identity): replace per-role IsInRoleAsync loop with single GetRolesAsync lookup GetUserRolesAsync issued one membership query per role in the tenant (N+1). One GetRolesAsync call now feeds a case-insensitive set lookup, matching ASP.NET Identity's normalized-name comparison semantics. Co-Authored-By: Claude Fable 5 --- .../Identity/Modules.Identity/Services/UserRoleService.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs b/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs index 097da45f08..52f55a60a3 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRoleService.cs @@ -49,6 +49,10 @@ public async Task> GetUserRolesAsync(string userId, Cancellati var roles = await roleManager.Roles.AsNoTracking().ToListAsync(cancellationToken) ?? throw new NotFoundException("roles not found"); + // Single membership query instead of one IsInRoleAsync round-trip per role. + var memberships = await userManager.GetRolesAsync(user); + var membershipSet = new HashSet(memberships, StringComparer.OrdinalIgnoreCase); + var userRoles = new List(); foreach (var role in roles) { @@ -57,7 +61,7 @@ public async Task> GetUserRolesAsync(string userId, Cancellati RoleId = role.Id, RoleName = role.Name, Description = role.Description, - Enabled = await userManager.IsInRoleAsync(user, role.Name!) + Enabled = membershipSet.Contains(role.Name!) }); } From 13358424f768ac5847957757914b8c4de852da7b Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Sat, 13 Jun 2026 01:47:15 +0530 Subject: [PATCH 2/5] fix(identity): stop logging email addresses in event handler log templates PII minimization: user registration, token generation, and welcome-email failure logs now identify users by the pseudonymous UserId only. The email previously rode along in three message templates and flowed into every exported log sink. Co-Authored-By: Claude Fable 5 --- .../Modules.Identity/Events/TokenGeneratedLogHandler.cs | 4 ++-- .../Modules.Identity/Events/UserRegisteredEmailHandler.cs | 3 ++- .../Modules.Identity/Events/UserRegisteredEventHandler.cs | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Modules/Identity/Modules.Identity/Events/TokenGeneratedLogHandler.cs b/src/Modules/Identity/Modules.Identity/Events/TokenGeneratedLogHandler.cs index 4a18288ee4..71894f075e 100644 --- a/src/Modules/Identity/Modules.Identity/Events/TokenGeneratedLogHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Events/TokenGeneratedLogHandler.cs @@ -24,10 +24,10 @@ public Task HandleAsync(TokenGeneratedIntegrationEvent @event, CancellationToken if (_logger.IsEnabled(LogLevel.Information)) { + // PII minimization: log the pseudonymous UserId only, not the email address. _logger.LogInformation( - "Token generated for user {UserId} ({Email}) with client {ClientId}, IP {IpAddress}, UserAgent {UserAgent}, expires at {ExpiresAtUtc} (fingerprint: {Fingerprint})", + "Token generated for user {UserId} with client {ClientId}, IP {IpAddress}, UserAgent {UserAgent}, expires at {ExpiresAtUtc} (fingerprint: {Fingerprint})", @event.UserId, - @event.Email, @event.ClientId, @event.IpAddress, @event.UserAgent, diff --git a/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs b/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs index c5ce04ac2f..5971ea21a6 100644 --- a/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs @@ -45,7 +45,8 @@ public async Task HandleAsync(UserRegisteredIntegrationEvent @event, Cancellatio { // Email failures must not break user registration. // The email can be retried via the outbox/dead-letter mechanism. - _logger.LogWarning(ex, "Failed to send welcome email to {Email}", @event.Email); + // PII minimization: identify the recipient by UserId, not email address. + _logger.LogWarning(ex, "Failed to send welcome email to user {UserId}", @event.UserId); } } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEventHandler.cs b/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEventHandler.cs index b2428a6c54..6fea7ee249 100644 --- a/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEventHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEventHandler.cs @@ -21,10 +21,10 @@ public async ValueTask Handle(UserRegisteredEvent notification, CancellationToke if (logger.IsEnabled(LogLevel.Information)) { + // PII minimization: log the pseudonymous UserId only, not the email address. logger.LogInformation( - "User registered: {UserId} ({Email})", - notification.UserId, - notification.Email); + "User registered: {UserId}", + notification.UserId); } var integrationEvent = new UserRegisteredIntegrationEvent( From ea91c38ddae426461cc3de05aeac904d218765c9 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Sat, 13 Jun 2026 01:47:15 +0530 Subject: [PATCH 3/5] refactor(chat): drop dead ChannelMember.IsMuted across domain, contract, and schema The property was get-only and never set anywhere, so it always serialized as false: dead weight in the domain, ChannelMemberDto, the dashboard client type, and a chat.ChannelMembers column. Removed end-to-end with migration DropChannelMemberIsMuted. Deliberate contract change: ChannelMemberDto loses a field no consumer ever read (verified across both React apps and all tests). Co-Authored-By: Claude Fable 5 --- clients/dashboard/src/api/chat.ts | 1 - clients/dashboard/tests/chat/chat.spec.ts | 1 - ...01021_DropChannelMemberIsMuted.Designer.cs | 378 ++++++++++++++++++ ...20260612201021_DropChannelMemberIsMuted.cs | 31 ++ .../Chat/ChatDbContextModelSnapshot.cs | 3 - .../v1/DTOs/ChannelMemberDto.cs | 3 +- .../ChannelMemberConfiguration.cs | 1 - .../Chat/Modules.Chat/Domain/ChannelMember.cs | 1 - .../Features/v1/Internal/ChatMappers.cs | 2 +- 9 files changed, 411 insertions(+), 10 deletions(-) create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/20260612201021_DropChannelMemberIsMuted.Designer.cs create mode 100644 src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/20260612201021_DropChannelMemberIsMuted.cs diff --git a/clients/dashboard/src/api/chat.ts b/clients/dashboard/src/api/chat.ts index 8590f47bd9..086e34b3c1 100644 --- a/clients/dashboard/src/api/chat.ts +++ b/clients/dashboard/src/api/chat.ts @@ -22,7 +22,6 @@ export type ChannelMemberDto = { role: ChannelMemberRoleValue; joinedAtUtc: string; lastReadMessageId?: string | null; - isMuted: boolean; }; export type ChannelDto = { diff --git a/clients/dashboard/tests/chat/chat.spec.ts b/clients/dashboard/tests/chat/chat.spec.ts index b1c3c6e412..b01711f5a3 100644 --- a/clients/dashboard/tests/chat/chat.spec.ts +++ b/clients/dashboard/tests/chat/chat.spec.ts @@ -51,7 +51,6 @@ const CHANNEL_ENGINEERING = { role: "Admin", joinedAtUtc: "2026-05-01T10:00:00Z", lastReadMessageId: null, - isMuted: false, }, ], }; diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/20260612201021_DropChannelMemberIsMuted.Designer.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/20260612201021_DropChannelMemberIsMuted.Designer.cs new file mode 100644 index 0000000000..7dcfcee044 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/20260612201021_DropChannelMemberIsMuted.Designer.cs @@ -0,0 +1,378 @@ +// +using System; +using FSH.Modules.Chat.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.Chat +{ + [DbContext(typeof(ChatDbContext))] + [Migration("20260612201021_DropChannelMemberIsMuted")] + partial class DropChannelMemberIsMuted + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("chat") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Chat.Domain.ChannelMember", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("JoinedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastReadMessageId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("UserId"); + + b.HasIndex("UserId", "ChannelId", "TenantId") + .IsUnique() + .HasDatabaseName("IX_ChannelMembers_UserId_ChannelId"); + + b.ToTable("ChannelMembers", "chat"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Chat.Domain.ChatChannel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DeletedBy") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DeletedOnUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("DirectKey") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("LastMessageAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Slug") + .HasMaxLength(220) + .HasColumnType("character varying(220)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("IsDeleted"); + + b.HasIndex("DirectKey", "TenantId") + .IsUnique() + .HasDatabaseName("IX_Channels_DirectKey") + .HasFilter("\"Type\" = 0 AND \"IsDeleted\" = FALSE"); + + b.HasIndex("Slug", "TenantId") + .IsUnique() + .HasDatabaseName("IX_Channels_Slug") + .HasFilter("\"Slug\" IS NOT NULL AND \"IsDeleted\" = FALSE"); + + b.ToTable("Channels", "chat"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Chat.Domain.Message", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AuthorUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Body") + .HasColumnType("text"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EditedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPinned") + .HasColumnType("boolean"); + + b.Property("ParentMessageId") + .HasColumnType("uuid"); + + b.Property("PinnedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PinnedByUserId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ReplyCount") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ParentMessageId") + .HasFilter("\"ParentMessageId\" IS NOT NULL"); + + b.HasIndex("ChannelId", "Id") + .IsDescending(false, true); + + b.HasIndex("ChannelId", "IsPinned") + .HasFilter("\"IsPinned\" = true"); + + b.ToTable("Messages", "chat"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Chat.Domain.MessageAttachment", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("FileAssetId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.HasIndex("MessageId"); + + b.ToTable("MessageAttachments", "chat"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Chat.Domain.MessageMention", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Length") + .HasColumnType("integer"); + + b.Property("MentionedUserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("StartIndex") + .HasColumnType("integer"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("MentionedUserId"); + + b.HasIndex("MessageId"); + + b.ToTable("MessageMentions", "chat"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Chat.Domain.MessageReaction", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Emoji") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("MessageId", "UserId", "Emoji", "TenantId") + .IsUnique() + .HasDatabaseName("UX_MessageReactions_Message_User_Emoji"); + + b.ToTable("MessageReactions", "chat"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); + + modelBuilder.Entity("FSH.Modules.Chat.Domain.ChannelMember", b => + { + b.HasOne("FSH.Modules.Chat.Domain.ChatChannel", null) + .WithMany("Members") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Chat.Domain.Message", b => + { + b.HasOne("FSH.Modules.Chat.Domain.ChatChannel", null) + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Chat.Domain.MessageAttachment", b => + { + b.HasOne("FSH.Modules.Chat.Domain.Message", null) + .WithMany("Attachments") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Chat.Domain.MessageMention", b => + { + b.HasOne("FSH.Modules.Chat.Domain.Message", null) + .WithMany("Mentions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Chat.Domain.MessageReaction", b => + { + b.HasOne("FSH.Modules.Chat.Domain.Message", null) + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FSH.Modules.Chat.Domain.ChatChannel", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("FSH.Modules.Chat.Domain.Message", b => + { + b.Navigation("Attachments"); + + b.Navigation("Mentions"); + + b.Navigation("Reactions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/20260612201021_DropChannelMemberIsMuted.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/20260612201021_DropChannelMemberIsMuted.cs new file mode 100644 index 0000000000..4c13bed774 --- /dev/null +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/20260612201021_DropChannelMemberIsMuted.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Starter.Migrations.PostgreSQL.Chat +{ + /// + public partial class DropChannelMemberIsMuted : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsMuted", + schema: "chat", + table: "ChannelMembers"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsMuted", + schema: "chat", + table: "ChannelMembers", + type: "boolean", + nullable: false, + defaultValue: false); + } + } +} diff --git a/src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/ChatDbContextModelSnapshot.cs b/src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/ChatDbContextModelSnapshot.cs index f37d2ddd22..c5c18c72f0 100644 --- a/src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/ChatDbContextModelSnapshot.cs +++ b/src/Host/FSH.Starter.Migrations.PostgreSQL/Chat/ChatDbContextModelSnapshot.cs @@ -31,9 +31,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ChannelId") .HasColumnType("uuid"); - b.Property("IsMuted") - .HasColumnType("boolean"); - b.Property("JoinedAtUtc") .HasColumnType("timestamp with time zone"); diff --git a/src/Modules/Chat/Modules.Chat.Contracts/v1/DTOs/ChannelMemberDto.cs b/src/Modules/Chat/Modules.Chat.Contracts/v1/DTOs/ChannelMemberDto.cs index 6e377b6fee..d0e90e7949 100644 --- a/src/Modules/Chat/Modules.Chat.Contracts/v1/DTOs/ChannelMemberDto.cs +++ b/src/Modules/Chat/Modules.Chat.Contracts/v1/DTOs/ChannelMemberDto.cs @@ -5,5 +5,4 @@ public sealed record ChannelMemberDto( string UserId, ChannelMemberRole Role, DateTime JoinedAtUtc, - Guid? LastReadMessageId, - bool IsMuted); + Guid? LastReadMessageId); diff --git a/src/Modules/Chat/Modules.Chat/Data/Configurations/ChannelMemberConfiguration.cs b/src/Modules/Chat/Modules.Chat/Data/Configurations/ChannelMemberConfiguration.cs index f206550544..c3a34213bc 100644 --- a/src/Modules/Chat/Modules.Chat/Data/Configurations/ChannelMemberConfiguration.cs +++ b/src/Modules/Chat/Modules.Chat/Data/Configurations/ChannelMemberConfiguration.cs @@ -20,7 +20,6 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Role).IsRequired().HasConversion(); builder.Property(x => x.JoinedAtUtc).IsRequired(); builder.Property(x => x.LastReadMessageId); - builder.Property(x => x.IsMuted).IsRequired(); builder.HasIndex(x => new { x.UserId, x.ChannelId }).IsUnique(); builder.HasIndex(x => x.UserId); diff --git a/src/Modules/Chat/Modules.Chat/Domain/ChannelMember.cs b/src/Modules/Chat/Modules.Chat/Domain/ChannelMember.cs index 5d3a994cea..2b0910ac92 100644 --- a/src/Modules/Chat/Modules.Chat/Domain/ChannelMember.cs +++ b/src/Modules/Chat/Modules.Chat/Domain/ChannelMember.cs @@ -10,7 +10,6 @@ public sealed class ChannelMember : BaseEntity public ChannelMemberRole Role { get; private set; } public DateTime JoinedAtUtc { get; private set; } public Guid? LastReadMessageId { get; private set; } - public bool IsMuted { get; } private ChannelMember() { } diff --git a/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChatMappers.cs b/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChatMappers.cs index c2eb879e32..c19fc2f6d4 100644 --- a/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChatMappers.cs +++ b/src/Modules/Chat/Modules.Chat/Features/v1/Internal/ChatMappers.cs @@ -6,7 +6,7 @@ namespace FSH.Modules.Chat.Features.v1.Internal; internal static class ChatMappers { public static ChannelMemberDto ToDto(this ChannelMember m) => - new(m.Id, m.UserId, m.Role, m.JoinedAtUtc, m.LastReadMessageId, m.IsMuted); + new(m.Id, m.UserId, m.Role, m.JoinedAtUtc, m.LastReadMessageId); public static ChannelDto ToDto(this ChatChannel c, int unreadCount = 0) => new( From 1077c3645e861559f50a3147974ee40d8ef598bd Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Sat, 13 Jun 2026 01:47:37 +0530 Subject: [PATCH 4/5] fix(dashboard): timeout token refresh, rule-9 comment mutation, richer chat error toasts - refreshAccessToken now aborts after the standard 30s timeout; a stalled refresh previously hung the shared refreshPromise and every queued 401-retry behind it (admin already had this guard). - Ticket comment composer passes the body through mutate(arg) instead of closed-over state (golden rule #9) and gains an aria-label. - Chat DM/delete/pin failure toasts now include the ProblemDetails description instead of a bare generic message. Co-Authored-By: Claude Fable 5 --- clients/dashboard/src/lib/api-client.ts | 3 +++ clients/dashboard/src/pages/chat/message.tsx | 7 ++++--- clients/dashboard/src/pages/tickets/ticket-detail.tsx | 6 ++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/clients/dashboard/src/lib/api-client.ts b/clients/dashboard/src/lib/api-client.ts index 6ee8bedecc..f37f45f226 100644 --- a/clients/dashboard/src/lib/api-client.ts +++ b/clients/dashboard/src/lib/api-client.ts @@ -119,6 +119,9 @@ export async function refreshAccessToken() { ...(tenant ? { tenant } : {}), }, body: JSON.stringify({ token: accessToken, refreshToken }), + // A stalled refresh would otherwise hang forever and block every queued + // 401-retry awaiting the shared refreshPromise. + signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), }); if (!response.ok) { diff --git a/clients/dashboard/src/pages/chat/message.tsx b/clients/dashboard/src/pages/chat/message.tsx index 4ef86b9359..8ab25c76c1 100644 --- a/clients/dashboard/src/pages/chat/message.tsx +++ b/clients/dashboard/src/pages/chat/message.tsx @@ -34,6 +34,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/cn"; +import { describe } from "@/lib/list-helpers"; import { useUserByUsername, useUserDisplay } from "@/lib/use-user-display"; import { usePresence } from "@/realtime/use-presence"; import { groupReactions, shortTime } from "@/pages/chat/chat-utils"; @@ -568,7 +569,7 @@ function MentionPill({ username }: { username: string }) { setOpen(false); navigate(`/chat/${channelId}`); }, - onError: () => toast.error("Couldn't open DM"), + onError: (err) => toast.error("Couldn't open DM", { description: describe(err) }), }); const displayName = @@ -719,7 +720,7 @@ function MessageActions({ setConfirmingDelete(false); toast.success("Message deleted"); }, - onError: () => toast.error("Couldn't delete the message"), + onError: (err) => toast.error("Couldn't delete the message", { description: describe(err) }), }); const pinMutation = useMutation({ @@ -733,7 +734,7 @@ function MessageActions({ void queryClient.invalidateQueries({ queryKey: ["chat", "pinned", message.channelId] }); toast.success(message.isPinned ? "Unpinned" : "Pinned"); }, - onError: () => toast.error("Couldn't update the pin."), + onError: (err) => toast.error("Couldn't update the pin", { description: describe(err) }), }); if (isDeleted) return null; diff --git a/clients/dashboard/src/pages/tickets/ticket-detail.tsx b/clients/dashboard/src/pages/tickets/ticket-detail.tsx index 2395790967..a112a0fa0c 100644 --- a/clients/dashboard/src/pages/tickets/ticket-detail.tsx +++ b/clients/dashboard/src/pages/tickets/ticket-detail.tsx @@ -372,7 +372,8 @@ function CommentsSection({ const [body, setBody] = useState(""); const mutation = useMutation({ - mutationFn: () => addTicketComment(ticketId, body.trim()), + // Per-call data flows through mutate(arg), never closed-over state (golden rule #9). + mutationFn: (text: string) => addTicketComment(ticketId, text), onSuccess: () => { setBody(""); toast.success("Comment posted"); @@ -416,7 +417,7 @@ function CommentsSection({ onSubmit={(e) => { e.preventDefault(); if (!body.trim() || disabled) return; - mutation.mutate(); + mutation.mutate(body.trim()); }} className={cn( "mt-5 rounded-xl border bg-[var(--color-card)]", @@ -427,6 +428,7 @@ function CommentsSection({