diff --git a/Core/Resgrid.Localization/Common.ar.resx b/Core/Resgrid.Localization/Common.ar.resx index dd4dc289..f9fe0a17 100644 --- a/Core/Resgrid.Localization/Common.ar.resx +++ b/Core/Resgrid.Localization/Common.ar.resx @@ -36,10 +36,10 @@ اتصال جهات الاتصال - Chat + الدردشة - Assistant + المساعد مراجعة المحتوى diff --git a/Core/Resgrid.Localization/Common.de.resx b/Core/Resgrid.Localization/Common.de.resx index 5a77731e..f1256ad6 100644 --- a/Core/Resgrid.Localization/Common.de.resx +++ b/Core/Resgrid.Localization/Common.de.resx @@ -162,7 +162,7 @@ Chat - Assistant + Assistent Moderation diff --git a/Core/Resgrid.Localization/Common.es.resx b/Core/Resgrid.Localization/Common.es.resx index cbcd2dd4..fb6971c6 100644 --- a/Core/Resgrid.Localization/Common.es.resx +++ b/Core/Resgrid.Localization/Common.es.resx @@ -181,7 +181,7 @@ Certificaciones - Cerca + Cerrar Cerrado @@ -208,7 +208,7 @@ Chat - Assistant + Asistente Moderación diff --git a/Core/Resgrid.Localization/Common.fr.resx b/Core/Resgrid.Localization/Common.fr.resx index bdac4533..33142dd6 100644 --- a/Core/Resgrid.Localization/Common.fr.resx +++ b/Core/Resgrid.Localization/Common.fr.resx @@ -159,7 +159,7 @@ Contacts - Chat + Discussion Assistant diff --git a/Core/Resgrid.Localization/Common.it.resx b/Core/Resgrid.Localization/Common.it.resx index 0e586af2..80ee8a8a 100644 --- a/Core/Resgrid.Localization/Common.it.resx +++ b/Core/Resgrid.Localization/Common.it.resx @@ -162,7 +162,7 @@ Chat - Assistant + Assistente Moderazione diff --git a/Core/Resgrid.Localization/Common.pl.resx b/Core/Resgrid.Localization/Common.pl.resx index 8a89b2ec..d8c95b59 100644 --- a/Core/Resgrid.Localization/Common.pl.resx +++ b/Core/Resgrid.Localization/Common.pl.resx @@ -159,10 +159,10 @@ Kontakty - Chat + Czat - Assistant + Asystent Moderacja diff --git a/Core/Resgrid.Localization/Common.sv.resx b/Core/Resgrid.Localization/Common.sv.resx index 365f1d32..c9761d73 100644 --- a/Core/Resgrid.Localization/Common.sv.resx +++ b/Core/Resgrid.Localization/Common.sv.resx @@ -159,10 +159,10 @@ Kontakter - Chat + Chatt - Assistant + Assistent Moderering diff --git a/Core/Resgrid.Localization/Common.uk.resx b/Core/Resgrid.Localization/Common.uk.resx index 769a1e92..1436f122 100644 --- a/Core/Resgrid.Localization/Common.uk.resx +++ b/Core/Resgrid.Localization/Common.uk.resx @@ -159,10 +159,10 @@ Контакти - Chat + Чат - Assistant + Асистент Модерація diff --git a/Core/Resgrid.Model/Services/IChatServices.cs b/Core/Resgrid.Model/Services/IChatServices.cs index 165ed8bd..09c2eaed 100644 --- a/Core/Resgrid.Model/Services/IChatServices.cs +++ b/Core/Resgrid.Model/Services/IChatServices.cs @@ -151,6 +151,9 @@ public interface IChatPermissionService /// Task> ResolveChannelAudienceUserIdsAsync(ChatChannel channel); + /// True when the user can administer the department (used to widen provisioning/visibility, e.g. every group default channel). + Task IsDepartmentAdminAsync(int departmentId, string userId); + /// Drops cached permission evaluations for a channel (membership/roles changed) and bumps the channel-list cache version. Task InvalidateChannelCacheAsync(string chatChannelId); } diff --git a/Core/Resgrid.Model/Services/IFeatureToggleService.cs b/Core/Resgrid.Model/Services/IFeatureToggleService.cs index 078b278e..cdec04aa 100644 --- a/Core/Resgrid.Model/Services/IFeatureToggleService.cs +++ b/Core/Resgrid.Model/Services/IFeatureToggleService.cs @@ -16,7 +16,11 @@ public interface IFeatureToggleService { #region Evaluation (hot path) - /// Returns whether a flag is enabled for a department, falling back to defaultValue when unknown. + /// + /// Returns whether a flag is enabled for a department, falling back to defaultValue when the flag + /// is unknown. Never throws: if evaluation itself fails (flag store unreachable) the error is + /// logged and the answer is false — gated features fail shut instead of 500ing their callers. + /// Task IsEnabledAsync(string key, int departmentId, bool defaultValue = false, IDictionary context = null); /// Full evaluation including the value and the reason (source) it resolved that way. diff --git a/Core/Resgrid.Services/ChatChannelService.cs b/Core/Resgrid.Services/ChatChannelService.cs index 941e7e79..abb37c96 100644 --- a/Core/Resgrid.Services/ChatChannelService.cs +++ b/Core/Resgrid.Services/ChatChannelService.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using Newtonsoft.Json; using Resgrid.Config; +using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Events; using Resgrid.Model.Providers; @@ -84,12 +85,51 @@ async Task> getChannels() if (departmentChannel != null) results[departmentChannel.ChatChannelId] = departmentChannel; - var group = await _departmentGroupsService.GetGroupForUserAsync(userId, departmentId); - if (group != null) + // Loaded once: source for the admin group-channel matching here AND the + // implicit-audience pass further down. + var allChannels = await _chatChannelRepository.GetAllByDepartmentIdAsync(departmentId, includeArchived); + + // Department admins get every group's default channel; everyone else gets only the group + // they belong to. Existing channels come from the bulk load above — only groups with no + // channel yet hit the provisioning path, and a failure there is contained per group so one + // bad group can never blank the admin's whole channel list. + if (await _chatPermissionService.IsDepartmentAdminAsync(departmentId, userId)) + { + var allGroups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(departmentId); + if (allGroups != null && allGroups.Count > 0) + { + var groupChannelsByGroupId = allChannels? + .Where(c => c.ChannelType == (int)ChatChannelType.GroupDefault && c.GroupId.HasValue) + .GroupBy(c => c.GroupId.Value) + .ToDictionary(g => g.Key, g => g.First()); + + foreach (var departmentGroup in allGroups) + { + try + { + ChatChannel groupChannel; + if (groupChannelsByGroupId == null || !groupChannelsByGroupId.TryGetValue(departmentGroup.DepartmentGroupId, out groupChannel)) + groupChannel = await EnsureGroupChannelAsync(departmentGroup); + + if (groupChannel != null) + results[groupChannel.ChatChannelId] = groupChannel; + } + catch (Exception ex) + { + Logging.LogException(ex); + } + } + } + } + else { - var groupChannel = await EnsureGroupChannelAsync(group); - if (groupChannel != null) - results[groupChannel.ChatChannelId] = groupChannel; + var group = await _departmentGroupsService.GetGroupForUserAsync(userId, departmentId); + if (group != null) + { + var groupChannel = await EnsureGroupChannelAsync(group); + if (groupChannel != null) + results[groupChannel.ChatChannelId] = groupChannel; + } } // Chatbot channels are provisioned when a chatbot session starts — the list path only @@ -115,7 +155,6 @@ async Task> getChannels() // Implicit-audience channels (custom rule-based + active incident channels): evaluate access // per channel; evaluations are cached by the permission service. - var allChannels = await _chatChannelRepository.GetAllByDepartmentIdAsync(departmentId, includeArchived); if (allChannels != null) { foreach (var channel in allChannels) diff --git a/Core/Resgrid.Services/ChatPermissionService.cs b/Core/Resgrid.Services/ChatPermissionService.cs index 22226fb1..4a22a1bc 100644 --- a/Core/Resgrid.Services/ChatPermissionService.cs +++ b/Core/Resgrid.Services/ChatPermissionService.cs @@ -682,7 +682,7 @@ private async Task AddUnitCrewAsync(int unitId, HashSet userIds) AddIfSet(userIds, role.UserId); } - private async Task IsDepartmentAdminAsync(int departmentId, string userId) + public async Task IsDepartmentAdminAsync(int departmentId, string userId) { return await _authorizationService.CanUserModifyDepartmentAsync(userId, departmentId); } diff --git a/Core/Resgrid.Services/FeatureToggleService.cs b/Core/Resgrid.Services/FeatureToggleService.cs index 79135290..1936e3eb 100644 --- a/Core/Resgrid.Services/FeatureToggleService.cs +++ b/Core/Resgrid.Services/FeatureToggleService.cs @@ -64,8 +64,20 @@ public FeatureToggleService(IFeatureFlagRepository featureFlagRepository, IFeatu public async Task IsEnabledAsync(string key, int departmentId, bool defaultValue = false, IDictionary context = null) { - var evaluation = await EvaluateInternalAsync(key, departmentId, context, defaultValue, new HashSet()); - return evaluation.IsEnabled; + try + { + var evaluation = await EvaluateInternalAsync(key, departmentId, context, defaultValue, new HashSet()); + return evaluation.IsEnabled; + } + catch (Exception ex) + { + // Fail shut: a flag-store outage (cache AND database unreachable) must never 500 every + // gated page/endpoint — the feature simply reads as disabled until the store recovers. + // Deliberately ignores defaultValue here: that is the "flag not defined" default, not + // the "evaluation infrastructure down" answer. + Logging.LogException(ex, $"FeatureToggle evaluation failed for '{key}' in department {departmentId}; failing shut (disabled)"); + return false; + } } public async Task EvaluateAsync(string key, int departmentId, IDictionary context = null) diff --git a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs index dcd7b7f2..f09cd091 100644 --- a/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ChatChannelServiceTests.cs @@ -524,6 +524,107 @@ public async Task implicit_channel_without_membership_should_create_row() } } + [TestFixture] + public class when_listing_channels_for_users : with_the_chat_channel_service + { + private ChatChannel SetupDepartmentChannel() + { + var departmentChannel = new ChatChannel + { + ChatChannelId = "dept-chan", + DepartmentId = 1, + ChannelType = (int)ChatChannelType.DepartmentDefault, + Name = "First Battalion", + CreatedOn = DateTime.UtcNow + }; + _chatChannelRepositoryMock.Setup(x => x.GetDepartmentDefaultAsync(1)).ReturnsAsync(departmentChannel); + return departmentChannel; + } + + [Test] + public async Task department_admin_should_get_every_group_channel_provisioned() + { + SetupDepartmentChannel(); + _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "admin-a")).ReturnsAsync(true); + _departmentGroupsServiceMock.Setup(x => x.GetAllGroupsForDepartmentAsync(1)).ReturnsAsync(new List + { + new DepartmentGroup { DepartmentGroupId = 9, DepartmentId = 1, Name = "Station 1" }, + new DepartmentGroup { DepartmentGroupId = 10, DepartmentId = 1, Name = "Station 2" } + }); + _chatChannelRepositoryMock.Setup(x => x.GetByGroupIdAsync(It.IsAny())).ReturnsAsync((ChatChannel)null); + + var result = await _chatChannelService.GetChannelsForUserAsync(1, "admin-a", null); + + result.Should().Contain(c => c.ChannelType == (int)ChatChannelType.GroupDefault && c.GroupId == 9); + result.Should().Contain(c => c.ChannelType == (int)ChatChannelType.GroupDefault && c.GroupId == 10); + _chatChannelRepositoryMock.Verify(x => x.InsertAsync(It.Is(c => c.ChannelType == (int)ChatChannelType.GroupDefault), + It.IsAny(), It.IsAny()), Times.Exactly(2)); + _departmentGroupsServiceMock.Verify(x => x.GetGroupForUserAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task admin_existing_group_channels_should_come_from_bulk_load_without_per_group_lookups() + { + SetupDepartmentChannel(); + _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "admin-a")).ReturnsAsync(true); + _departmentGroupsServiceMock.Setup(x => x.GetAllGroupsForDepartmentAsync(1)).ReturnsAsync(new List + { + new DepartmentGroup { DepartmentGroupId = 9, DepartmentId = 1, Name = "Station 1" }, + new DepartmentGroup { DepartmentGroupId = 10, DepartmentId = 1, Name = "Station 2" } + }); + _chatChannelRepositoryMock.Setup(x => x.GetAllByDepartmentIdAsync(1, false)).ReturnsAsync(new List + { + new ChatChannel { ChatChannelId = "group-9", DepartmentId = 1, ChannelType = (int)ChatChannelType.GroupDefault, GroupId = 9, Name = "Station 1" }, + new ChatChannel { ChatChannelId = "group-10", DepartmentId = 1, ChannelType = (int)ChatChannelType.GroupDefault, GroupId = 10, Name = "Station 2" } + }); + + var result = await _chatChannelService.GetChannelsForUserAsync(1, "admin-a", null); + + result.Should().Contain(c => c.ChatChannelId == "group-9"); + result.Should().Contain(c => c.ChatChannelId == "group-10"); + _chatChannelRepositoryMock.Verify(x => x.GetByGroupIdAsync(It.IsAny()), Times.Never); + _chatChannelRepositoryMock.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task admin_single_group_provisioning_failure_should_not_abort_the_channel_list() + { + SetupDepartmentChannel(); + _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "admin-a")).ReturnsAsync(true); + _departmentGroupsServiceMock.Setup(x => x.GetAllGroupsForDepartmentAsync(1)).ReturnsAsync(new List + { + new DepartmentGroup { DepartmentGroupId = 9, DepartmentId = 1, Name = "Station 1" }, + new DepartmentGroup { DepartmentGroupId = 10, DepartmentId = 1, Name = "Station 2" } + }); + _chatChannelRepositoryMock.Setup(x => x.GetByGroupIdAsync(9)).ThrowsAsync(new InvalidOperationException("db down")); + _chatChannelRepositoryMock.Setup(x => x.GetByGroupIdAsync(10)).ReturnsAsync((ChatChannel)null); + + var result = await _chatChannelService.GetChannelsForUserAsync(1, "admin-a", null); + + result.Should().Contain(c => c.ChannelType == (int)ChatChannelType.GroupDefault && c.GroupId == 10); + result.Should().Contain(c => c.ChannelType == (int)ChatChannelType.DepartmentDefault); + } + + [Test] + public async Task non_admin_should_only_get_their_own_group_channel() + { + SetupDepartmentChannel(); + _chatPermissionServiceMock.Setup(x => x.IsDepartmentAdminAsync(1, "user-a")).ReturnsAsync(false); + _departmentGroupsServiceMock.Setup(x => x.GetGroupForUserAsync("user-a", 1)).ReturnsAsync(new DepartmentGroup + { + DepartmentGroupId = 9, + DepartmentId = 1, + Name = "Station 1" + }); + _chatChannelRepositoryMock.Setup(x => x.GetByGroupIdAsync(9)).ReturnsAsync((ChatChannel)null); + + var result = await _chatChannelService.GetChannelsForUserAsync(1, "user-a", null); + + result.Should().ContainSingle(c => c.ChannelType == (int)ChatChannelType.GroupDefault).Which.GroupId.Should().Be(9); + _departmentGroupsServiceMock.Verify(x => x.GetAllGroupsForDepartmentAsync(It.IsAny()), Times.Never); + } + } + [TestFixture] public class when_creating_ad_hoc_group_channels : with_the_chat_channel_service { diff --git a/Tests/Resgrid.Tests/Services/FeatureToggleServiceTests.cs b/Tests/Resgrid.Tests/Services/FeatureToggleServiceTests.cs new file mode 100644 index 00000000..e242609e --- /dev/null +++ b/Tests/Resgrid.Tests/Services/FeatureToggleServiceTests.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + namespace FeatureToggleServiceTests + { + public class with_the_feature_toggle_service : TestBase + { + protected Mock _featureFlagRepositoryMock; + protected IFeatureToggleService _featureToggleService; + + protected with_the_feature_toggle_service() + { + BuildService(); + } + + protected override void Before_all_tests() + { + BuildService(); + } + + private void BuildService() + { + _featureFlagRepositoryMock = new Mock(); + + _featureToggleService = new FeatureToggleService( + _featureFlagRepositoryMock.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object); + } + } + + [TestFixture] + public class when_the_flag_store_is_unavailable : with_the_feature_toggle_service + { + [Test] + public async Task is_enabled_should_fail_shut_and_not_throw() + { + _featureFlagRepositoryMock.Setup(x => x.GetAllAsync()).ThrowsAsync(new InvalidOperationException("flag store down")); + + var result = await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.ChatSystem, 1); + + result.Should().BeFalse(); + } + + [Test] + public async Task is_enabled_should_fail_shut_even_when_default_value_is_true() + { + _featureFlagRepositoryMock.Setup(x => x.GetAllAsync()).ThrowsAsync(new InvalidOperationException("flag store down")); + + // defaultValue answers "flag not defined", not "flag store down" — outages always read disabled. + var result = await _featureToggleService.IsEnabledAsync(FeatureFlagKeys.ChatSystem, 1, defaultValue: true); + + result.Should().BeFalse(); + } + } + + [TestFixture] + public class when_a_flag_is_not_defined : with_the_feature_toggle_service + { + [Test] + public async Task is_enabled_should_return_the_caller_default() + { + _featureFlagRepositoryMock.Setup(x => x.GetAllAsync()).ReturnsAsync(new List()); + + var result = await _featureToggleService.IsEnabledAsync("Tests.NoSuchFlag", 1, defaultValue: true); + + result.Should().BeTrue(); + } + } + } +} diff --git a/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj b/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj index 8461bfa4..744eecaa 100644 --- a/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj +++ b/Web/Resgrid.Web.Eventing/Resgrid.Web.Eventing.csproj @@ -62,6 +62,8 @@ + + diff --git a/Web/Resgrid.Web.Eventing/Startup.cs b/Web/Resgrid.Web.Eventing/Startup.cs index ff3c063d..2bcce853 100644 --- a/Web/Resgrid.Web.Eventing/Startup.cs +++ b/Web/Resgrid.Web.Eventing/Startup.cs @@ -332,6 +332,10 @@ public void ConfigureContainer(ContainerBuilder builder) builder.RegisterModule(new MarketingModule()); builder.RegisterModule(new PdfProviderModule()); builder.RegisterModule(new MessagingProviderModule()); + // The chat hub's service chain (ChatChannelService -> ChatPermissionService -> + // IncidentCommandService -> IncidentVoiceService) needs the weather and voip providers. + builder.RegisterModule(new Resgrid.Providers.Voip.VoipProviderModule()); + builder.RegisterModule(new Resgrid.Providers.Weather.WeatherProviderModule()); builder.RegisterType().As>().InstancePerLifetimeScope(); builder.RegisterType().As>().InstancePerLifetimeScope(); diff --git a/Web/Resgrid.Web.Tts/Health/TtsDependencyHealthCheck.cs b/Web/Resgrid.Web.Tts/Health/TtsDependencyHealthCheck.cs index cb4361ec..2088e119 100644 --- a/Web/Resgrid.Web.Tts/Health/TtsDependencyHealthCheck.cs +++ b/Web/Resgrid.Web.Tts/Health/TtsDependencyHealthCheck.cs @@ -9,13 +9,16 @@ public sealed class TtsDependencyHealthCheck : IHealthCheck { private readonly S3StorageOptions _s3Options; private readonly TtsOptions _ttsOptions; + private readonly ILogger _logger; public TtsDependencyHealthCheck( IOptions s3Options, - IOptions ttsOptions) + IOptions ttsOptions, + ILogger logger) { _s3Options = s3Options.Value; _ttsOptions = ttsOptions.Value; + _logger = logger; } public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) @@ -57,6 +60,44 @@ public Task CheckHealthAsync(HealthCheckContext context, Canc validationErrors.Add("Redis connection string is not configured."); } + // Synthesis writes Piper/ffmpeg intermediates under TempDirectory; with a + // read-only root filesystem this only works when the temp volume is actually + // mounted, so prove writability here where the k8s probes will see it fail + // instead of surfacing as 500s on the first uncached prompt. + try + { + var tempRoot = Path.GetFullPath(string.IsNullOrWhiteSpace(_ttsOptions.TempDirectory) + ? Path.GetTempPath() + : _ttsOptions.TempDirectory); + + Directory.CreateDirectory(tempRoot); + + // Mirror the synthesis workload (AudioProcessingService): a per-job child directory + // under the temp root with the intermediates written inside it. + var probeDirectory = Path.Combine(tempRoot, $"health-probe-{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(probeDirectory); + File.WriteAllBytes(Path.Combine(probeDirectory, "probe.tmp"), new byte[] { 1 }); + } + finally + { + try + { + Directory.Delete(probeDirectory, recursive: true); + } + catch (DirectoryNotFoundException) + { + // Creation itself failed; nothing to clean up. + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "TTS temp directory probe failed for {TempDirectory}", _ttsOptions.TempDirectory); + validationErrors.Add($"The TTS temp directory '{_ttsOptions.TempDirectory}' is not writable: {ex.Message}"); + } + if (validationErrors.Count == 0) { return Task.FromResult(HealthCheckResult.Healthy("TTS configuration is ready.")); diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/AssistantPanelElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/AssistantPanelElement.tsx new file mode 100644 index 00000000..00630a59 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/AssistantPanelElement.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from 'react'; +import './chat.css'; +import ChatbotElement from './ChatbotElement'; +import { useChatStore } from './useChatStore'; + +export interface AssistantPanelElementProps { + hostElement?: HTMLElement; + // Localized strings supplied by the Razor host via element attributes (commonLocalizer). + label?: string; + closeLabel?: string; +} + +// Footer button + right-hand slide-out drawer hosting the assistant conversation. The assistant +// is intentionally not a standalone page: the drawer overlays whatever the user is working on. +export default function AssistantPanelElement({ hostElement, label = 'Assistant', closeLabel = 'Close' }: AssistantPanelElementProps) { + // Piggybacks on the chat store populated by 's bootstrap (both elements share the + // module store), so this element never issues its own channel fetch just to gate visibility. + const available = useChatStore((state) => state.chatAvailable); + const loaded = useChatStore((state) => state.channelsLoaded); + const [open, setOpen] = useState(false); + + const ready = loaded && available; + + useEffect(() => { + if (hostElement) { + hostElement.style.display = ready ? '' : 'none'; + } + }, [hostElement, ready]); + + useEffect(() => { + if (!open) { + return; + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [open]); + + if (!ready) { + return null; + } + + return ( + <> + + {open && ( +
+
+
+ + {label} +
+ +
+
+ {/* Mounted only while open, so the chatbot channel/session is provisioned lazily on first use. */} + +
+
+ )} + + ); +} diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChannelList.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChannelList.tsx index 7d3fd40d..f510d9ef 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChannelList.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChannelList.tsx @@ -9,6 +9,8 @@ interface ChannelListProps { activeChannelId: string | null; filter?: string; loading?: boolean; + loadFailed?: boolean; + onRetry?: () => void; onSelect: (channelId: string) => void; } @@ -56,9 +58,24 @@ export function ChannelListSkeleton() { ); } -export default function ChannelList({ channels, activeChannelId, filter, loading, onSelect }: ChannelListProps) { +export default function ChannelList({ channels, activeChannelId, filter, loading, loadFailed, onRetry, onSelect }: ChannelListProps) { const messagesByChannel = useChatStore((state) => state.messagesByChannel); + // Load failures leave channelsLoaded false, so check the error state before the skeleton. + if (loadFailed) { + return ( +
+ +
Couldn't load conversations.
+ {onRetry && ( + + )} +
+ ); + } + if (loading) { return ; } diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx index b931b2ca..a2043ce9 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPageElement.tsx @@ -24,7 +24,7 @@ export interface ChatPageElementProps { } export default function ChatPageElement(_props: ChatPageElementProps) { - const { available, loaded } = useChatBootstrap({ connectImmediately: true }); + const { available, loaded, loadFailed, reload } = useChatBootstrap({ connectImmediately: true }); const channels = useChatStore((state) => state.channels, shallowArrayEqual); const [activeChannelId, setActiveChannelId] = useState(null); @@ -114,7 +114,15 @@ export default function ChatPageElement(_props: ChatPageElementProps) { + - +
diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx index 06a8aa9d..8d67e473 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/ChatPanelElement.tsx @@ -15,10 +15,12 @@ import { NoticeToast, AuthErrorNotice } from './atoms/StatusBanners'; export interface ChatPanelElementProps { hostElement?: HTMLElement; + // Localized label supplied by the Razor host via the element attribute (commonLocalizer). + label?: string; } -export default function ChatPanelElement({ hostElement }: ChatPanelElementProps) { - const { available, loaded, connect } = useChatBootstrap(); +export default function ChatPanelElement({ hostElement, label = 'Chat' }: ChatPanelElementProps) { + const { available, loaded, loadFailed, reload, connect } = useChatBootstrap(); const channels = useChatStore((state) => state.channels, shallowArrayEqual); const unread = useChatStore((state) => state.channels.reduce((total, channel) => total + Math.max(0, channel.UnreadCount), 0)); @@ -32,11 +34,17 @@ export default function ChatPanelElement({ hostElement }: ChatPanelElementProps) const currentUserId = getCurrentUserId(); const activeChannel = channels.find((channel) => channel.ChatChannelId === activeChannelId) ?? null; + // Feature-flag gate: the footer button stays hidden until the server confirms the + // Chat.System flag is on for this department (GetChannels 404s when it is off). + // A non-404 load failure keeps the button visible so the failure view (with its + // retry control) stays reachable — only a confirmed flag-off hides chat. + const chatVisible = loadFailed || (loaded && available); + useEffect(() => { if (hostElement) { - hostElement.style.display = loaded && !available ? 'none' : ''; + hostElement.style.display = chatVisible ? '' : 'none'; } - }, [hostElement, loaded, available]); + }, [hostElement, chatVisible]); const openPanel = () => { setOpen(true); @@ -50,17 +58,19 @@ export default function ChatPanelElement({ hostElement }: ChatPanelElementProps) setThread(null); }; - if (loaded && !available) { + if (!chatVisible) { return null; } if (!open) { + // Collapsed state renders inline inside the site footer (see _Footer.cshtml) so the + // button never overlaps page content the way the old floating FAB did. return ( -
- + )} diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chat.css b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chat.css index 211e6bf3..dfdf58c7 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chat.css +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chat.css @@ -39,36 +39,41 @@ border: 0; } -/* ---- Floating panel ---- */ -.rgchat-fab { - position: fixed; - right: 24px; - bottom: 24px; - z-index: 12000; +/* ---- Footer toggle button ---- */ +rg-chat { + display: inline-block; + vertical-align: middle; +} + +/* Rendered inline inside the fixed site footer (right side), so it never overlaps page + content. The footer stays light in both color schemes, so colors are fixed here. */ +.rgchat-footerbtn { display: inline-flex; align-items: center; - gap: 8px; - padding: 12px 18px; + gap: 6px; + padding: 4px 14px; border: none; border-radius: 999px; - background: var(--rgchat-dark); + background: #1ab394; color: #fff; - font-size: 14px; + font-size: 13px; font-weight: 600; + line-height: 20px; cursor: pointer; - box-shadow: 0 10px 30px rgba(15, 23, 42, 0.28); - transition: transform 160ms ease, box-shadow 160ms ease; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.18); + transition: background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; } -.rgchat-fab:hover { +.rgchat-footerbtn:hover { + background: #18a689; transform: translateY(-1px); - box-shadow: 0 14px 34px rgba(15, 23, 42, 0.34); + box-shadow: 0 4px 10px rgba(15, 23, 42, 0.24); } -.rgchat-fab__badge { - min-width: 20px; - height: 20px; - padding: 0 6px; +.rgchat-footerbtn__badge { + min-width: 18px; + height: 18px; + padding: 0 5px; border-radius: 999px; background: var(--rgchat-urgent); color: #fff; @@ -79,14 +84,16 @@ justify-content: center; } +/* ---- Floating panel ---- */ .rgchat-panel { position: fixed; right: 24px; - bottom: 24px; + /* Clears the fixed site footer so the panel never covers the copyright bar. */ + bottom: 56px; z-index: 12000; width: 384px; height: 560px; - max-height: calc(100vh - 48px); + max-height: calc(100vh - 80px); display: flex; flex-direction: column; background: var(--rgchat-panel); @@ -1370,6 +1377,78 @@ } } +/* ---- Assistant footer button + slide-out drawer ---- */ +rg-assistant { + display: inline-block; + vertical-align: middle; + margin-right: 8px; +} + +.rgchat-footerbtn--assistant { + background: #2f4050; +} + +.rgchat-footerbtn--assistant:hover { + background: #263544; +} + +.rgchat-drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + z-index: 12050; + width: 420px; + max-width: calc(100vw - 24px); + display: flex; + flex-direction: column; + background: var(--rgchat-panel); + border-left: 1px solid var(--rgchat-border); + box-shadow: -18px 0 48px rgba(15, 23, 42, 0.25); + animation: rgchat-drawer-in 200ms ease; +} + +@keyframes rgchat-drawer-in { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +.rgchat-drawer__head { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: var(--rgchat-dark); + color: #fff; +} + +.rgchat-drawer__head .rgchat-panel__title { + flex: 1; +} + +.rgchat-drawer__body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* The embedded chatbot fills the drawer edge-to-edge instead of its standalone framed layout. */ +.rgchat-drawer__body .rgchat-root { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.rgchat-drawer__body .rgchat-botframe { + flex: 1; + height: auto; + min-height: 0; + border: 0; + border-radius: 0; +} + /* ---- Chatbot ---- */ .rgchat-botframe { height: calc(100vh - 220px); @@ -1552,58 +1631,9 @@ margin: 8px 0; } -/* ---- Dark mode ---- */ -@media (prefers-color-scheme: dark) { - .rgchat-root { - --rgchat-dark: #141c26; - --rgchat-bg: #131a23; - --rgchat-panel: #1c2632; - --rgchat-border: #2c3a4a; - --rgchat-muted: #9aa8b8; - --rgchat-ink: #e6edf4; - --rgchat-own-bg: #123c33; - --rgchat-own-ink: #d9f3ea; - --rgchat-own-border: #1f5c4c; - --rgchat-hover: rgba(255, 255, 255, 0.07); - --rgchat-bubble-bg: #223041; - --rgchat-input-bg: #141c26; - --rgchat-shadow-pop: 0 12px 30px rgba(0, 0, 0, 0.55); - } - - .rgchat-daydivider { - background: rgba(255, 255, 255, 0.09); - } - - .rgchat-chan--active { - background: rgba(26, 179, 148, 0.2); - } - - .rgchat-reaction--mine { - background: rgba(26, 179, 148, 0.22); - } - - .rgchat-skeleton { - background: linear-gradient(90deg, rgba(255, 255, 255, 0.06) 25%, rgba(255, 255, 255, 0.14) 50%, rgba(255, 255, 255, 0.06) 75%); - background-size: 200% 100%; - } - - .rgchat-bot .rgchat-bubble--bot { - background: #223152; - border-color: #31436e; - color: #e6edf4; - } - - .rgchat-tag { - background: rgba(255, 255, 255, 0.12); - color: #cdd8e4; - } - - .rgchat-error { - background: rgba(216, 54, 76, 0.14); - border-color: rgba(216, 54, 76, 0.4); - color: #f3b4bd; - } -} +/* NOTE: no dark-mode block on purpose. The Resgrid web app (Inspinia md-skin) is light-only, so the + chat surfaces stay light regardless of the OS color-scheme preference — a dark chat pane on the + white app chrome looked broken. */ /* ---- Reduced motion ---- */ @media (prefers-reduced-motion: reduce) { diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatStore.ts b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatStore.ts index 0ceae8a9..b9fe1037 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatStore.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/chatStore.ts @@ -23,6 +23,7 @@ export type ChatConnectionStatus = 'connected' | 'reconnecting' | 'offline'; export interface ChatState { chatAvailable: boolean; channelsLoaded: boolean; + channelsLoadFailed: boolean; channels: ChatChannelDto[]; messagesByChannel: Record; threadMessagesByRoot: Record; @@ -46,6 +47,7 @@ const TYPING_TTL_MS = 5000; let state: ChatState = { chatAvailable: true, channelsLoaded: false, + channelsLoadFailed: false, channels: [], messagesByChannel: {}, threadMessagesByRoot: {}, @@ -132,7 +134,13 @@ export function setChatAvailable(available: boolean): void { } export function setChannels(channels: ChatChannelDto[]): void { - setState({ channels, channelsLoaded: true }); + setState({ channels, channelsLoaded: true, channelsLoadFailed: false }); +} + +export function setChannelsLoadFailed(failed: boolean): void { + if (state.channelsLoadFailed !== failed) { + setState({ channelsLoadFailed: failed }); + } } export function upsertChannel(channel: ChatChannelDto): void { diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/useChatBootstrap.ts b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/useChatBootstrap.ts index 6807ab2b..85fe94c4 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/useChatBootstrap.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/components/chat/useChatBootstrap.ts @@ -1,13 +1,14 @@ import { useCallback, useEffect, useRef } from 'react'; import { getChannels, getMyPendingAcks } from './chatApi'; import { chatHub } from './chatHub'; -import { setChannels, setChatAvailable, setPendingAcks, chatStore } from './chatStore'; +import { setChannels, setChannelsLoadFailed, setChatAvailable, setPendingAcks, chatStore } from './chatStore'; import { seedPresenceFor } from './chatActions'; import { useChatStore } from './useChatStore'; export interface ChatBootstrap { available: boolean; loaded: boolean; + loadFailed: boolean; reload: () => void; connect: () => void; } @@ -35,6 +36,7 @@ export function useChatBootstrap(options?: ChatBootstrapOptions): ChatBootstrap const connectedRef = useRef(false); const reload = useCallback(() => { + setChannelsLoadFailed(false); getChannels() .then((outcome) => { setChatAvailable(outcome.available); @@ -43,7 +45,12 @@ export function useChatBootstrap(options?: ChatBootstrapOptions): ChatBootstrap void seedPresenceFor(outcome.channels.map((channel) => channel.OwnerUserId)); } }) - .catch((error) => console.error('Failed to load chat channels.', error)); + .catch((error) => { + // Non-404 failure (network, 500, expired token): surface a retryable error state + // instead of leaving the skeleton loader up forever. + setChannelsLoadFailed(true); + console.error('Failed to load chat channels.', error); + }); }, []); const connect = useCallback(() => { @@ -69,9 +76,11 @@ export function useChatBootstrap(options?: ChatBootstrapOptions): ChatBootstrap .then((acks) => setPendingAcks(acks.map((ack) => ack.ChatMessageId))) .catch(() => undefined); - // Badge freshness while the hub is disconnected (FAB never opened on this page). + // Badge freshness while the hub is disconnected (panel never opened on this page). + // Skip once the server said chat is unavailable (404) — no point re-asking every poll. const poll = setInterval(() => { - if (chatStore.getState().connectionStatus !== 'connected') { + const state = chatStore.getState(); + if (state.chatAvailable && state.connectionStatus !== 'connected') { reload(); } }, BADGE_POLL_INTERVAL_MS); @@ -84,5 +93,6 @@ export function useChatBootstrap(options?: ChatBootstrapOptions): ChatBootstrap const available = useChatStore((state) => state.chatAvailable); const loaded = useChatStore((state) => state.channelsLoaded); - return { available, loaded, reload, connect }; + const loadFailed = useChatStore((state) => state.channelsLoadFailed); + return { available, loaded, loadFailed, reload, connect }; } diff --git a/Web/Resgrid.Web/Areas/User/Apps/src/elements.ts b/Web/Resgrid.Web/Areas/User/Apps/src/elements.ts index fb16d224..d328425e 100644 --- a/Web/Resgrid.Web/Areas/User/Apps/src/elements.ts +++ b/Web/Resgrid.Web/Areas/User/Apps/src/elements.ts @@ -4,6 +4,7 @@ import type { MapElementProps } from './components/map/MapElement'; import type { ShiftsCalendarElementProps } from './components/shifts/ShiftsCalendarElement'; import type { OmnibarElementProps } from './components/omnibar/OmnibarElement'; import type { ChatPanelElementProps } from './components/chat/ChatPanelElement'; +import type { AssistantPanelElementProps } from './components/chat/AssistantPanelElement'; import type { ChatPageElementProps } from './components/chat/ChatPageElement'; import type { ChatbotElementProps } from './components/chat/ChatbotElement'; import type { ChatModerationElementProps } from './components/chat/ChatModerationElement'; @@ -51,7 +52,16 @@ defineReactElement( defineReactElement( 'rg-chat', () => import('./components/chat/ChatPanelElement'), - [], + [{ attribute: 'label', property: 'label', type: 'string', defaultValue: 'Chat' }], +); + +defineReactElement( + 'rg-assistant', + () => import('./components/chat/AssistantPanelElement'), + [ + { attribute: 'label', property: 'label', type: 'string', defaultValue: 'Assistant' }, + { attribute: 'closelabel', property: 'closeLabel', type: 'string', defaultValue: 'Close' }, + ], ); defineReactElement( diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ChatController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ChatController.cs index b5960613..ad230a84 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ChatController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ChatController.cs @@ -1,6 +1,8 @@ +using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Resgrid.Providers.Claims; +using Resgrid.Model; +using Resgrid.Model.Services; namespace Resgrid.Web.Areas.User.Controllers { @@ -8,22 +10,38 @@ namespace Resgrid.Web.Areas.User.Controllers [Authorize] public class ChatController : SecureBaseController { + private readonly IFeatureToggleService _featureToggleService; + + public ChatController(IFeatureToggleService featureToggleService) + { + _featureToggleService = featureToggleService; + } + + private Task ChatEnabledAsync() + { + return _featureToggleService.IsEnabledAsync(FeatureFlagKeys.ChatSystem, DepartmentId); + } + /// /// Full-page Slack-style chat workspace, rendered by the <rg-chat-page> React element. /// [HttpGet] - public IActionResult Index() + public async Task Index() { + if (!await ChatEnabledAsync()) + return RedirectToAction("Dashboard", "Home", new { Area = "User" }); + return View(); } /// - /// AI assistant conversation, rendered by the <rg-chatbot> React element. + /// The assistant is no longer a standalone page: it lives in the <rg-assistant> footer + /// slide-out on every page. Old links land on the chat workspace. /// [HttpGet] public IActionResult Chatbot() { - return View(); + return RedirectToAction("Index"); } /// diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ModerationController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ModerationController.cs index 3a3e7dba..cd9c0f61 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ModerationController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ModerationController.cs @@ -11,15 +11,20 @@ namespace Resgrid.Web.Areas.User.Controllers public class ModerationController : SecureBaseController { private readonly IDepartmentGroupsService _departmentGroupsService; + private readonly IFeatureToggleService _featureToggleService; - public ModerationController(IDepartmentGroupsService departmentGroupsService) + public ModerationController(IDepartmentGroupsService departmentGroupsService, IFeatureToggleService featureToggleService) { _departmentGroupsService = departmentGroupsService; + _featureToggleService = featureToggleService; } [HttpGet] public async Task Index() { + if (!await _featureToggleService.IsEnabledAsync(Resgrid.Model.FeatureFlagKeys.ChatSystem, DepartmentId)) + return RedirectToAction("Dashboard", "Home", new { Area = "User" }); + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin() && !await _departmentGroupsService.IsUserAGroupAdminAsync(UserId, DepartmentId)) return Unauthorized(); diff --git a/Web/Resgrid.Web/Areas/User/Views/Chat/Chatbot.cshtml b/Web/Resgrid.Web/Areas/User/Views/Chat/Chatbot.cshtml deleted file mode 100644 index c096ecbe..00000000 --- a/Web/Resgrid.Web/Areas/User/Views/Chat/Chatbot.cshtml +++ /dev/null @@ -1,34 +0,0 @@ -@{ - ViewBag.Title = "Resgrid | " + commonLocalizer["AssistantModule"]; - Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; -} - -
-
-

@commonLocalizer["AssistantModule"]

- -
-
- -
-
-
-
-
- -
-
-
-
-
- -@section Scripts -{ -} diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_Footer.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_Footer.cshtml index 1dc44aba..eba1eb54 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_Footer.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_Footer.cshtml @@ -1,8 +1,9 @@  diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml index bde9393c..bb49d0e5 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_Navigation.cshtml @@ -1,4 +1,10 @@ -