From 3db6b35cbdb93a9c5842c9e53248ca3990963ea9 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 7 Aug 2026 17:16:18 -0700 Subject: [PATCH 1/2] RG-T117 Chat feature flag --- src/api/feature-flags/feature-flags.ts | 37 ++++++++++++ src/app/(app)/_layout.tsx | 16 ++++-- src/app/(app)/chat.tsx | 12 +++- src/app/(app)/chatbot.tsx | 15 ++++- src/app/chat/[channelId].tsx | 18 ++++-- src/app/chat/thread/[messageId].tsx | 12 +++- src/components/sidebar/sidebar-content.tsx | 7 ++- src/stores/feature-flags/store.ts | 67 ++++++++++++++++++++++ 8 files changed, 167 insertions(+), 17 deletions(-) create mode 100644 src/api/feature-flags/feature-flags.ts create mode 100644 src/stores/feature-flags/store.ts diff --git a/src/api/feature-flags/feature-flags.ts b/src/api/feature-flags/feature-flags.ts new file mode 100644 index 0000000..9840f04 --- /dev/null +++ b/src/api/feature-flags/feature-flags.ts @@ -0,0 +1,37 @@ +import { api } from '../common/client'; + +const FEATURE_TOGGLES = '/FeatureToggles'; + +// --------------------------------------------------------------------------- +// Feature toggle evaluation (department-scoped, any authenticated user). +// Backed by the v4 FeatureToggles API; keys live in Resgrid.Model.FeatureFlagKeys. +// --------------------------------------------------------------------------- + +export interface FeatureToggleData { + Key: string; + Enabled: boolean; + Value?: string | null; + ValueType?: string | null; + Source?: string | null; +} + +export interface FeatureTogglesResult { + Data?: FeatureToggleData[]; + StateHash?: string; +} + +export interface FeatureToggleResult { + Data?: FeatureToggleData; +} + +/** Evaluates every active flag for the caller's department. */ +export const getAllFeatureFlags = async (signal?: AbortSignal) => { + const response = await api.get(`${FEATURE_TOGGLES}/GetAll`, { signal }); + return response.data; +}; + +/** Lightweight enabled-only check for a single flag. */ +export const getFeatureFlagState = async (key: string, signal?: AbortSignal) => { + const response = await api.get(`${FEATURE_TOGGLES}/GetState`, { params: { key }, signal }); + return response.data; +}; diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index 0036b79..91d3114 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -35,6 +35,7 @@ import { usePushNotifications } from '@/services/push-notification'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; import { useCommandStore } from '@/stores/command/store'; +import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store'; import { useRolesStore } from '@/stores/roles/store'; import { securityStore } from '@/stores/security/store'; import { useSignalRStore } from '@/stores/signalr/signalr-store'; @@ -168,16 +169,21 @@ export default function TabLayout() { await useCallsStore.getState().init(); await useWeatherAlertsStore.getState().init(); await securityStore.getState().getRights(); + await featureFlagsStore.getState().fetchFlags(); await useSignalRStore.getState().connectUpdateHub(); await useSignalRStore.getState().connectGeolocationHub(); // Connect the realtime chat hub (best-effort; chat may be disabled per department) - try { - await useSignalRStore.getState().connectChatHub(); - logger.info({ message: 'SignalR chat hub connected successfully' }); - } catch (chatError) { - logger.warn({ message: 'Failed to connect SignalR chat hub during initialization', context: { error: chatError } }); + if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) { + try { + await useSignalRStore.getState().connectChatHub(); + logger.info({ message: 'SignalR chat hub connected successfully' }); + } catch (chatError) { + logger.warn({ message: 'Failed to connect SignalR chat hub during initialization', context: { error: chatError } }); + } + } else { + logger.info({ message: 'Chat disabled by feature flag; skipping chat hub connection' }); } // Hydrate incident-command boards from the server Sync Bundle (best-effort; offline-safe) diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx index 0b9cbd0..d927a33 100644 --- a/src/app/(app)/chat.tsx +++ b/src/app/(app)/chat.tsx @@ -1,4 +1,4 @@ -import { type Href, Stack, useFocusEffect, useRouter } from 'expo-router'; +import { type Href, Redirect, Stack, useFocusEffect, useRouter } from 'expo-router'; import { Bot, MessageCircle, MessagesSquare, Network, Plus, Sparkles, Users } from 'lucide-react-native'; import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -19,6 +19,7 @@ import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { type ChatChannelResultData, ChatChannelType } from '@/models/v4/chat'; import { useChatStore } from '@/stores/chat/store'; +import { useIsChatEnabled } from '@/stores/feature-flags/store'; function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPress: () => void }) { const { t } = useTranslation(); @@ -86,12 +87,14 @@ export default function ChatScreen() { const pendingAcks = useChatStore((s) => s.pendingAcks); const [fabOpen, setFabOpen] = useState(false); const [newMode, setNewMode] = useState<'dm' | 'group' | null>(null); + const isChatEnabled = useIsChatEnabled(); useFocusEffect( useCallback(() => { + if (!isChatEnabled) return; useChatStore.getState().fetchChannels(); useChatStore.getState().fetchPendingAcks(); - }, []) + }, [isChatEnabled]) ); const grouped = groupChannels(channels); @@ -103,6 +106,11 @@ export default function ChatScreen() { [router] ); + // Chat.System feature flag off: no chat for this department. + if (!isChatEnabled) { + return ; + } + return ( diff --git a/src/app/(app)/chatbot.tsx b/src/app/(app)/chatbot.tsx index d61e17c..38cf3c9 100644 --- a/src/app/(app)/chatbot.tsx +++ b/src/app/(app)/chatbot.tsx @@ -1,4 +1,4 @@ -import { Stack, useFocusEffect } from 'expo-router'; +import { Redirect, Stack, useFocusEffect } from 'expo-router'; import { RefreshCw, Send, Sparkles } from 'lucide-react-native'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -18,6 +18,7 @@ import { VStack } from '@/components/ui/vstack'; import { type ChatMessageResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; +import { useIsChatEnabled } from '@/stores/feature-flags/store'; export default function ChatbotScreen() { const { t } = useTranslation(); @@ -26,22 +27,25 @@ export default function ChatbotScreen() { const chatbotTyping = useChatStore((s) => s.chatbotTyping); const messages = useChatStore((s) => (chatbotChannelId ? s.messagesByChannel[chatbotChannelId] : undefined)); const [text, setText] = useState(''); + const isChatEnabled = useIsChatEnabled(); useFocusEffect( useCallback(() => { + if (!isChatEnabled) return; const store = useChatStore.getState(); void store.initChatbot(); return () => { useChatStore.getState().setActiveChannel(null); }; - }, []) + }, [isChatEnabled]) ); // Keep the assistant channel active while viewing so incoming messages don't inflate unread. useFocusEffect( useCallback(() => { + if (!isChatEnabled) return; if (chatbotChannelId) useChatStore.getState().setActiveChannel(chatbotChannelId); - }, [chatbotChannelId]) + }, [chatbotChannelId, isChatEnabled]) ); const inverted = useMemo(() => (messages ? messages.slice().reverse() : []), [messages]); @@ -60,6 +64,11 @@ export default function ChatbotScreen() { [currentUserId] ); + // Chat.System feature flag off: no chat for this department. + if (!isChatEnabled) { + return ; + } + return ( diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index c40542c..9cf2ef5 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -1,5 +1,5 @@ import { Image } from 'expo-image'; -import { type Href, Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; +import { type Href, Redirect, Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { Circle, ShieldCheck } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -26,6 +26,7 @@ import { VStack } from '@/components/ui/vstack'; import { ChatChannelType, ChatMessagePriority, type ChatMessageResultData, ChatMessageType, type GifResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; +import { useIsChatEnabled } from '@/stores/feature-flags/store'; import { securityStore } from '@/stores/security/store'; import { useToastStore } from '@/stores/toast/store'; @@ -43,6 +44,7 @@ export default function ChannelConversationScreen() { const currentUserId = useAuthStore((s) => s.userId); const isModerator = !!securityStore((s) => s.rights)?.IsAdmin; + const isChatEnabled = useIsChatEnabled(); const channel = useChatStore((s) => s.channels.find((c) => c.ChatChannelId === channelId)); const messages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined)); @@ -72,6 +74,7 @@ export default function ChannelConversationScreen() { // Mount: activate channel, join hub, load history and members. useFocusEffect( useCallback(() => { + if (!isChatEnabled) return; if (!channelId) return; const store = useChatStore.getState(); store.setActiveChannel(channelId); @@ -81,11 +84,12 @@ export default function ChannelConversationScreen() { return () => { useChatStore.getState().setActiveChannel(null); }; - }, [channelId]) + }, [channelId, isChatEnabled]) ); // Fetch presence for the channel members (for the header online dot). useEffect(() => { + if (!isChatEnabled) return; const ids = (members ?? []).map((m) => m.UserId).filter((id): id is string => !!id && id !== currentUserId); if (ids.length === 0) return; const controller = new AbortController(); @@ -97,14 +101,15 @@ export default function ChannelConversationScreen() { }) .catch(() => undefined); return () => controller.abort(); - }, [members, currentUserId]); + }, [members, currentUserId, isChatEnabled]); // Mark read whenever the newest message changes while viewing. useEffect(() => { + if (!isChatEnabled) return; if (channelId && inverted.length > 0) { void useChatStore.getState().markChannelRead(channelId); } - }, [channelId, inverted.length]); + }, [channelId, inverted.length, isChatEnabled]); const otherOnline = useMemo(() => { if (!isDm) return false; @@ -246,6 +251,11 @@ export default function ChannelConversationScreen() { const title = channel ? getChannelDisplayName(channel, t) : t('chat.title'); + // Chat.System feature flag off: no chat for this department. + if (!isChatEnabled) { + return ; + } + return ( s.channels.find((c) => c.ChatChannelId === channelId)); const channelMessages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined)); const [fetchedReplies, setFetchedReplies] = useState([]); + const isChatEnabled = useIsChatEnabled(); // IC delta: thread replies in command-type channels also post as the Incident Commander. const isCommandChannel = channel?.ChannelType === ChatChannelType.Incident || channel?.ChannelType === ChatChannelType.IncidentLane || channel?.ChannelType === ChatChannelType.IncidentCommand; @@ -33,11 +35,12 @@ export default function ThreadScreen() { const root = useMemo(() => (channelMessages ?? []).find((m) => m.ChatMessageId === messageId), [channelMessages, messageId]); useEffect(() => { + if (!isChatEnabled) return; if (!messageId) return; getThread(messageId, undefined, 50) .then((response) => setFetchedReplies(response.Data ?? [])) .catch((error) => logger.error({ message: 'chat: failed to load thread', context: { error, messageId } })); - }, [messageId]); + }, [messageId, isChatEnabled]); // Merge fetched replies with any realtime/optimistic replies already in the channel cache. const replies = useMemo(() => { @@ -100,6 +103,11 @@ export default function ThreadScreen() { [currentUserId, channelId] ); + // Chat.System feature flag off: no chat for this department. + if (!isChatEnabled) { + return ; + } + return ( diff --git a/src/components/sidebar/sidebar-content.tsx b/src/components/sidebar/sidebar-content.tsx index 0a3b811..50c2d45 100644 --- a/src/components/sidebar/sidebar-content.tsx +++ b/src/components/sidebar/sidebar-content.tsx @@ -9,6 +9,7 @@ import { Icon } from '@/components/ui/icon'; import { Pressable } from '@/components/ui/pressable'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; +import { useIsChatEnabled } from '@/stores/feature-flags/store'; interface SidebarProps { onClose?: () => void; @@ -35,6 +36,10 @@ const MENU_ITEMS: MenuItem[] = [ const Sidebar = ({ onClose }: SidebarProps) => { const { t } = useTranslation(); const router = useRouter(); + const isChatEnabled = useIsChatEnabled(); + + // Chat.System feature flag off: hide the chat and assistant entries entirely. + const menuItems = MENU_ITEMS.filter((item) => (item.key === 'chat' || item.key === 'chatbot' ? isChatEnabled : true)); const handleNavigate = (href: string) => { onClose?.(); @@ -46,7 +51,7 @@ const Sidebar = ({ onClose }: SidebarProps) => { {t('sidebar.menu')} - {MENU_ITEMS.map((item) => ( + {menuItems.map((item) => ( handleNavigate(item.href)}> {t(item.labelKey)} diff --git a/src/stores/feature-flags/store.ts b/src/stores/feature-flags/store.ts new file mode 100644 index 0000000..2cb7ffc --- /dev/null +++ b/src/stores/feature-flags/store.ts @@ -0,0 +1,67 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import { getAllFeatureFlags } from '@/api/feature-flags/feature-flags'; +import { logger } from '@/lib/logging'; + +import { zustandStorage } from '../../lib/storage'; + +// Well-known feature flag keys. Keep values in sync with Resgrid.Model.FeatureFlagKeys. +export const FeatureFlagKeys = { + ChatSystem: 'Chat.System', +} as const; + +export type FeatureFlagKey = (typeof FeatureFlagKeys)[keyof typeof FeatureFlagKeys]; + +interface FeatureFlagEntry { + enabled: boolean; + value?: string | null; +} + +export interface FeatureFlagsState { + flags: Record; + isLoaded: boolean; + error: string | null; + fetchFlags: () => Promise; + isEnabled: (key: string, defaultValue?: boolean) => boolean; +} + +export const featureFlagsStore = create()( + persist( + (set, get) => ({ + flags: {}, + isLoaded: false, + error: null, + fetchFlags: async () => { + try { + const response = await getAllFeatureFlags(); + const flags: Record = {}; + for (const flag of response?.Data ?? []) { + if (flag?.Key) { + flags[flag.Key] = { enabled: !!flag.Enabled, value: flag.Value ?? null }; + } + } + set({ flags, isLoaded: true, error: null }); + } catch (error) { + // Keep any persisted flags on failure so gating stays stable while offline. + logger.error({ + message: 'Failed to fetch feature flags', + context: { error }, + }); + set({ error: error instanceof Error ? error.message : 'Failed to fetch feature flags' }); + } + }, + isEnabled: (key: string, defaultValue = false) => get().flags[key]?.enabled ?? defaultValue, + }), + { + name: 'feature-flags-storage', + storage: createJSONStorage(() => zustandStorage), + } + ) +); + +// Reactive hook; components re-render when the flag changes. Unknown flags default to disabled +// so gated features stay hidden until the server confirms them. +export const useFeatureFlag = (key: string, defaultValue = false) => featureFlagsStore((state) => state.flags[key]?.enabled ?? defaultValue); + +export const useIsChatEnabled = () => useFeatureFlag(FeatureFlagKeys.ChatSystem); From 6ee2dd1a3ca75f2f0e0086247b43b9f5ab33aeb6 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 8 Aug 2026 08:46:14 -0700 Subject: [PATCH 2/2] RG-T117 PR#35 fixes --- src/app/(app)/chat.tsx | 19 +- src/app/(app)/chatbot.tsx | 21 +- src/app/chat/[channelId].tsx | 19 +- src/app/chat/thread/[messageId].tsx | 20 +- .../__tests__/app-reset.service.test.ts | 20 ++ src/services/app-reset.service.ts | 12 + .../feature-flags/__tests__/store.test.ts | 225 ++++++++++++++++++ src/stores/feature-flags/store.ts | 62 ++++- src/stores/signalr/signalr-store.ts | 7 + 9 files changed, 387 insertions(+), 18 deletions(-) create mode 100644 src/stores/feature-flags/__tests__/store.test.ts diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx index d927a33..1d232c4 100644 --- a/src/app/(app)/chat.tsx +++ b/src/app/(app)/chat.tsx @@ -15,11 +15,12 @@ import { Fab, FabIcon } from '@/components/ui/fab'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { HStack } from '@/components/ui/hstack'; import { Pressable } from '@/components/ui/pressable'; +import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { type ChatChannelResultData, ChatChannelType } from '@/models/v4/chat'; import { useChatStore } from '@/stores/chat/store'; -import { useIsChatEnabled } from '@/stores/feature-flags/store'; +import { useChatSystemStatus } from '@/stores/feature-flags/store'; function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPress: () => void }) { const { t } = useTranslation(); @@ -87,7 +88,8 @@ export default function ChatScreen() { const pendingAcks = useChatStore((s) => s.pendingAcks); const [fabOpen, setFabOpen] = useState(false); const [newMode, setNewMode] = useState<'dm' | 'group' | null>(null); - const isChatEnabled = useIsChatEnabled(); + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; useFocusEffect( useCallback(() => { @@ -106,8 +108,19 @@ export default function ChatScreen() { [router] ); + // Chat.System flag not yet resolved: wait instead of redirecting away from a valid route. + if (chatStatus === 'unknown') { + return ( + + + + + + ); + } + // Chat.System feature flag off: no chat for this department. - if (!isChatEnabled) { + if (chatStatus === 'disabled') { return ; } diff --git a/src/app/(app)/chatbot.tsx b/src/app/(app)/chatbot.tsx index 38cf3c9..f5259ab 100644 --- a/src/app/(app)/chatbot.tsx +++ b/src/app/(app)/chatbot.tsx @@ -13,12 +13,13 @@ import { HStack } from '@/components/ui/hstack'; import { Input, InputField } from '@/components/ui/input'; import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view'; import { Pressable } from '@/components/ui/pressable'; +import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { type ChatMessageResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; -import { useIsChatEnabled } from '@/stores/feature-flags/store'; +import { useChatSystemStatus } from '@/stores/feature-flags/store'; export default function ChatbotScreen() { const { t } = useTranslation(); @@ -27,7 +28,8 @@ export default function ChatbotScreen() { const chatbotTyping = useChatStore((s) => s.chatbotTyping); const messages = useChatStore((s) => (chatbotChannelId ? s.messagesByChannel[chatbotChannelId] : undefined)); const [text, setText] = useState(''); - const isChatEnabled = useIsChatEnabled(); + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; useFocusEffect( useCallback(() => { @@ -64,8 +66,19 @@ export default function ChatbotScreen() { [currentUserId] ); - // Chat.System feature flag off: no chat for this department. - if (!isChatEnabled) { + // Chat.System flag not yet resolved: wait instead of redirecting away from a valid route. + if (chatStatus === 'unknown') { + return ( + + + + + + ); + } + + // Chat.System feature flag off: the assistant rides on the chat system, hide it too. + if (chatStatus === 'disabled') { return ; } diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index 9cf2ef5..325ed68 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -26,7 +26,7 @@ import { VStack } from '@/components/ui/vstack'; import { ChatChannelType, ChatMessagePriority, type ChatMessageResultData, ChatMessageType, type GifResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; -import { useIsChatEnabled } from '@/stores/feature-flags/store'; +import { useChatSystemStatus } from '@/stores/feature-flags/store'; import { securityStore } from '@/stores/security/store'; import { useToastStore } from '@/stores/toast/store'; @@ -44,7 +44,8 @@ export default function ChannelConversationScreen() { const currentUserId = useAuthStore((s) => s.userId); const isModerator = !!securityStore((s) => s.rights)?.IsAdmin; - const isChatEnabled = useIsChatEnabled(); + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; const channel = useChatStore((s) => s.channels.find((c) => c.ChatChannelId === channelId)); const messages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined)); @@ -251,8 +252,18 @@ export default function ChannelConversationScreen() { const title = channel ? getChannelDisplayName(channel, t) : t('chat.title'); - // Chat.System feature flag off: no chat for this department. - if (!isChatEnabled) { + // Chat.System flag not yet resolved: wait instead of redirecting away from a valid deep link. + if (chatStatus === 'unknown') { + return ( + + + + + ); + } + + // Chat.System feature flag off: block deep links (push notifications, stale routes). + if (chatStatus === 'disabled') { return ; } diff --git a/src/app/chat/thread/[messageId].tsx b/src/app/chat/thread/[messageId].tsx index 10aa028..f5dfa44 100644 --- a/src/app/chat/thread/[messageId].tsx +++ b/src/app/chat/thread/[messageId].tsx @@ -9,13 +9,14 @@ import { MessageComposer } from '@/components/chat/message-composer'; import { Box } from '@/components/ui/box'; import { Divider } from '@/components/ui/divider'; import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view'; +import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { logger } from '@/lib/logging'; import { ChatChannelType, ChatMessagePriority, type ChatMessageResultData, ChatMessageType } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; -import { useIsChatEnabled } from '@/stores/feature-flags/store'; +import { useChatSystemStatus } from '@/stores/feature-flags/store'; export default function ThreadScreen() { const { t } = useTranslation(); @@ -27,7 +28,8 @@ export default function ThreadScreen() { const channel = useChatStore((s) => s.channels.find((c) => c.ChatChannelId === channelId)); const channelMessages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined)); const [fetchedReplies, setFetchedReplies] = useState([]); - const isChatEnabled = useIsChatEnabled(); + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; // IC delta: thread replies in command-type channels also post as the Incident Commander. const isCommandChannel = channel?.ChannelType === ChatChannelType.Incident || channel?.ChannelType === ChatChannelType.IncidentLane || channel?.ChannelType === ChatChannelType.IncidentCommand; @@ -103,8 +105,18 @@ export default function ThreadScreen() { [currentUserId, channelId] ); - // Chat.System feature flag off: no chat for this department. - if (!isChatEnabled) { + // Chat.System flag not yet resolved: wait instead of redirecting away from a valid deep link. + if (chatStatus === 'unknown') { + return ( + + + + + ); + } + + // Chat.System feature flag off: block deep links into threads. + if (chatStatus === 'disabled') { return ; } diff --git a/src/services/__tests__/app-reset.service.test.ts b/src/services/__tests__/app-reset.service.test.ts index 4fc72be..ee0d728 100644 --- a/src/services/__tests__/app-reset.service.test.ts +++ b/src/services/__tests__/app-reset.service.test.ts @@ -136,6 +136,13 @@ jest.mock('@/stores/security/store', () => ({ }, })); +jest.mock('@/stores/feature-flags/store', () => ({ + featureFlagsStore: { + setState: jest.fn(), + getState: jest.fn(() => ({})), + }, +})); + jest.mock('@/stores/units/store', () => ({ useUnitsStore: { setState: jest.fn(), @@ -153,6 +160,7 @@ import { INITIAL_CONTACTS_STATE, INITIAL_CORE_STATE, INITIAL_DISPATCH_STATE, + INITIAL_FEATURE_FLAGS_STATE, INITIAL_LIVEKIT_STATE, INITIAL_LOCATION_STATE, INITIAL_NOTES_STATE, @@ -309,6 +317,15 @@ describe('app-reset.service', () => { }); }); + it('should export INITIAL_FEATURE_FLAGS_STATE with correct shape', () => { + expect(INITIAL_FEATURE_FLAGS_STATE).toEqual({ + flags: {}, + isLoaded: false, + error: null, + identityKey: null, + }); + }); + it('should export INITIAL_LOCATION_STATE with correct shape', () => { expect(INITIAL_LOCATION_STATE).toEqual({ latitude: null, @@ -388,12 +405,15 @@ describe('app-reset.service', () => { const { useCoreStore } = jest.requireMock('@/stores/app/core-store'); const { useCallsStore } = jest.requireMock('@/stores/calls/store'); const { useUnitsStore } = jest.requireMock('@/stores/units/store'); + const { featureFlagsStore } = jest.requireMock('@/stores/feature-flags/store'); await resetAllStores(); expect(useCoreStore.setState).toHaveBeenCalledWith(INITIAL_CORE_STATE); expect(useCallsStore.setState).toHaveBeenCalledWith(INITIAL_CALLS_STATE); expect(useUnitsStore.setState).toHaveBeenCalledWith(INITIAL_UNITS_STATE); + // Logout must clear in-memory flags and identity so the next session fails closed. + expect(featureFlagsStore.setState).toHaveBeenCalledWith(INITIAL_FEATURE_FLAGS_STATE); expect(mockOfflineQueueClear).toHaveBeenCalled(); expect(mockLoadingReset).toHaveBeenCalled(); expect(mockAudioCleanup).toHaveBeenCalled(); diff --git a/src/services/app-reset.service.ts b/src/services/app-reset.service.ts index b986eac..f65c2eb 100644 --- a/src/services/app-reset.service.ts +++ b/src/services/app-reset.service.ts @@ -18,6 +18,7 @@ import { useLocationStore } from '@/stores/app/location-store'; import { useCallsStore } from '@/stores/calls/store'; import { useContactsStore } from '@/stores/contacts/store'; import { useDispatchStore } from '@/stores/dispatch/store'; +import { featureFlagsStore } from '@/stores/feature-flags/store'; import { useNotesStore } from '@/stores/notes/store'; import { useOfflineQueueStore } from '@/stores/offline-queue/store'; import { useProtocolsStore } from '@/stores/protocols/store'; @@ -118,6 +119,15 @@ export const INITIAL_SECURITY_STATE = { rights: null, }; +// Logout clears MMKV but not in-memory zustand state; reset here so the next session starts +// unknown and fails closed until fetchFlags resolves, instead of gating on the old identity's flags. +export const INITIAL_FEATURE_FLAGS_STATE = { + flags: {}, + isLoaded: false, + error: null, + identityKey: null, +}; + export const INITIAL_LOCATION_STATE = { latitude: null, longitude: null, @@ -192,6 +202,7 @@ export const resetAllStores = async (): Promise => { useProtocolsStore.setState(INITIAL_PROTOCOLS_STATE); useDispatchStore.setState(INITIAL_DISPATCH_STATE); securityStore.setState(INITIAL_SECURITY_STATE); + featureFlagsStore.setState(INITIAL_FEATURE_FLAGS_STATE); // Stores with existing reset/clear methods useOfflineQueueStore.getState().clearAllEvents(); @@ -282,6 +293,7 @@ export default { INITIAL_PROTOCOLS_STATE, INITIAL_DISPATCH_STATE, INITIAL_SECURITY_STATE, + INITIAL_FEATURE_FLAGS_STATE, INITIAL_LOCATION_STATE, INITIAL_LIVEKIT_STATE, INITIAL_AUDIO_STREAM_STATE, diff --git a/src/stores/feature-flags/__tests__/store.test.ts b/src/stores/feature-flags/__tests__/store.test.ts new file mode 100644 index 0000000..322b288 --- /dev/null +++ b/src/stores/feature-flags/__tests__/store.test.ts @@ -0,0 +1,225 @@ +import { renderHook } from '@testing-library/react-native'; + +import { FeatureFlagKeys, featureFlagsStore, useChatSystemStatus } from '../store'; + +// Mock the API +jest.mock('@/api/feature-flags/feature-flags', () => ({ + getAllFeatureFlags: jest.fn(), +})); + +// Mock logging +jest.mock('@/lib/logging', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})); + +// Mock the storage +jest.mock('../../../lib/storage', () => ({ + zustandStorage: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, +})); + +// Mock identity sources +jest.mock('../../auth/store', () => ({ + __esModule: true, + default: { + getState: jest.fn(), + }, +})); + +jest.mock('../../security/store', () => ({ + securityStore: { + getState: jest.fn(), + }, +})); + +const { getAllFeatureFlags } = require('@/api/feature-flags/feature-flags'); +const useAuthStore = require('../../auth/store').default; +const { securityStore } = require('../../security/store'); + +const setIdentity = (userId: string | null, departmentId: string | null) => { + useAuthStore.getState.mockReturnValue({ userId }); + securityStore.getState.mockReturnValue({ + rights: departmentId ? { DepartmentId: departmentId } : null, + }); +}; + +describe('Feature Flags Store', () => { + beforeEach(() => { + jest.clearAllMocks(); + featureFlagsStore.setState({ + flags: {}, + isLoaded: false, + error: null, + identityKey: null, + }); + setIdentity('user-1', 'dept-1'); + }); + + describe('fetchFlags', () => { + it('should store flags and stamp the current identity on success', async () => { + getAllFeatureFlags.mockResolvedValue({ + Data: [{ Key: FeatureFlagKeys.ChatSystem, Enabled: true, Value: null }], + }); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags[FeatureFlagKeys.ChatSystem]).toEqual({ enabled: true, value: null }); + expect(state.isLoaded).toBe(true); + expect(state.error).toBeNull(); + expect(state.identityKey).toBe('user-1:dept-1'); + }); + + it('should keep persisted flags on failure for the same identity', async () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-1:dept-1', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags[FeatureFlagKeys.ChatSystem]?.enabled).toBe(true); + expect(state.identityKey).toBe('user-1:dept-1'); + expect(state.error).toBe('network down'); + }); + + it('should clear flags from a different department before fetching so a failed fetch cannot reuse them', async () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-1:dept-old', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags).toEqual({}); + // Fail-closed: the failed fetch still resolves the flags so consumers stop waiting. + expect(state.isLoaded).toBe(true); + expect(state.identityKey).toBeNull(); + }); + + it('should clear flags from a different account before fetching', async () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-other:dept-1', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + expect(featureFlagsStore.getState().flags).toEqual({}); + }); + + it('should replace another identity flags with fresh ones on success', async () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-other:dept-other', + }); + getAllFeatureFlags.mockResolvedValue({ + Data: [{ Key: FeatureFlagKeys.ChatSystem, Enabled: false, Value: null }], + }); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags[FeatureFlagKeys.ChatSystem]?.enabled).toBe(false); + expect(state.identityKey).toBe('user-1:dept-1'); + }); + + it('should keep flags on failure when department is unknown but the user matches', async () => { + setIdentity('user-1', null); + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-1:dept-1', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags[FeatureFlagKeys.ChatSystem]?.enabled).toBe(true); + expect(state.identityKey).toBe('user-1:dept-1'); + }); + + it('should clear flags when department is unknown and the user differs', async () => { + setIdentity('user-2', null); + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-1:dept-1', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + expect(featureFlagsStore.getState().flags).toEqual({}); + }); + }); + + describe('useChatSystemStatus', () => { + it('should be unknown before the initial fetch resolves', () => { + const { result } = renderHook(() => useChatSystemStatus()); + + expect(result.current).toBe('unknown'); + }); + + it('should report enabled and disabled from the flag entry', () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + }); + const { result: enabled } = renderHook(() => useChatSystemStatus()); + expect(enabled.current).toBe('enabled'); + + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: false, value: null } }, + }); + const { result: disabled } = renderHook(() => useChatSystemStatus()); + expect(disabled.current).toBe('disabled'); + }); + + it('should resolve disabled when flags loaded without an entry', () => { + featureFlagsStore.setState({ flags: {}, isLoaded: true }); + + const { result } = renderHook(() => useChatSystemStatus()); + + expect(result.current).toBe('disabled'); + }); + + it('should resolve disabled (fail-closed) after a failed fetch with no persisted flags', async () => { + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + const { result } = renderHook(() => useChatSystemStatus()); + expect(result.current).toBe('disabled'); + }); + }); + + describe('isEnabled', () => { + it('should return the flag state when present and the default when missing', () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + }); + + expect(featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)).toBe(true); + expect(featureFlagsStore.getState().isEnabled('Unknown.Flag')).toBe(false); + expect(featureFlagsStore.getState().isEnabled('Unknown.Flag', true)).toBe(true); + }); + }); +}); diff --git a/src/stores/feature-flags/store.ts b/src/stores/feature-flags/store.ts index 2cb7ffc..9bdc590 100644 --- a/src/stores/feature-flags/store.ts +++ b/src/stores/feature-flags/store.ts @@ -5,6 +5,8 @@ import { getAllFeatureFlags } from '@/api/feature-flags/feature-flags'; import { logger } from '@/lib/logging'; import { zustandStorage } from '../../lib/storage'; +import useAuthStore from '../auth/store'; +import { securityStore } from '../security/store'; // Well-known feature flag keys. Keep values in sync with Resgrid.Model.FeatureFlagKeys. export const FeatureFlagKeys = { @@ -18,10 +20,38 @@ interface FeatureFlagEntry { value?: string | null; } +// Immutable ids only (user id + department id) so renames/code changes never alias identities. +const getCurrentIdentityKey = (): string | null => { + const userId = useAuthStore.getState().userId; + const departmentId = securityStore.getState().rights?.DepartmentId; + if (!userId || !departmentId) { + return null; + } + return `${userId}:${departmentId}`; +}; + +// True only when the persisted flags provably belong to a different account/department. +// With no proof (e.g. rights unavailable offline) flags are kept so gating stays stable. +const isPersistedIdentityStale = (persistedKey: string | null): boolean => { + if (!persistedKey) { + return false; + } + const userId = useAuthStore.getState().userId; + const departmentId = securityStore.getState().rights?.DepartmentId; + if (userId && departmentId) { + return persistedKey !== `${userId}:${departmentId}`; + } + if (userId) { + return !persistedKey.startsWith(`${userId}:`); + } + return false; +}; + export interface FeatureFlagsState { flags: Record; isLoaded: boolean; error: string | null; + identityKey: string | null; fetchFlags: () => Promise; isEnabled: (key: string, defaultValue?: boolean) => boolean; } @@ -32,7 +62,14 @@ export const featureFlagsStore = create()( flags: {}, isLoaded: false, error: null, + identityKey: null, fetchFlags: async () => { + const identityKey = getCurrentIdentityKey(); + if (isPersistedIdentityStale(get().identityKey)) { + // Persisted flags belong to another account/department; drop them before fetching + // so a failed fetch can never gate this identity with the previous one's flags. + set({ flags: {}, isLoaded: false, identityKey: null }); + } try { const response = await getAllFeatureFlags(); const flags: Record = {}; @@ -41,14 +78,17 @@ export const featureFlagsStore = create()( flags[flag.Key] = { enabled: !!flag.Enabled, value: flag.Value ?? null }; } } - set({ flags, isLoaded: true, error: null }); + set({ flags, isLoaded: true, error: null, identityKey }); } catch (error) { - // Keep any persisted flags on failure so gating stays stable while offline. + // Keep persisted flags on failure so gating stays stable while offline; the mismatch + // check above already cleared them if they belonged to a different identity. Marking + // isLoaded resolves flags with no persisted entry fail-closed (disabled) instead of + // leaving consumers waiting on 'unknown' forever. logger.error({ message: 'Failed to fetch feature flags', context: { error }, }); - set({ error: error instanceof Error ? error.message : 'Failed to fetch feature flags' }); + set({ error: error instanceof Error ? error.message : 'Failed to fetch feature flags', isLoaded: true }); } }, isEnabled: (key: string, defaultValue = false) => get().flags[key]?.enabled ?? defaultValue, @@ -65,3 +105,19 @@ export const featureFlagsStore = create()( export const useFeatureFlag = (key: string, defaultValue = false) => featureFlagsStore((state) => state.flags[key]?.enabled ?? defaultValue); export const useIsChatEnabled = () => useFeatureFlag(FeatureFlagKeys.ChatSystem); + +export type FeatureFlagStatus = 'unknown' | 'enabled' | 'disabled'; + +// Tri-state hook for gating that must not act before flags resolve (e.g. redirecting away +// from a deep link). 'unknown' until flags for this identity are fetched or rehydrated; +// fetch failures resolve fail-closed as 'disabled' for flags with no persisted entry. +export const useFeatureFlagStatus = (key: string): FeatureFlagStatus => + featureFlagsStore((state) => { + const entry = state.flags[key]; + if (entry) { + return entry.enabled ? 'enabled' : 'disabled'; + } + return state.isLoaded ? 'disabled' : 'unknown'; + }); + +export const useChatSystemStatus = (): FeatureFlagStatus => useFeatureFlagStatus(FeatureFlagKeys.ChatSystem); diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 28197a7..c5f2795 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -8,6 +8,7 @@ import { signalRService } from '@/services/signalr.service'; import { useCoreStore } from '../app/core-store'; import { useChatStore } from '../chat/store'; import { useCommandStore } from '../command/store'; +import { FeatureFlagKeys, featureFlagsStore } from '../feature-flags/store'; import { securityStore, useSecurityStore } from '../security/store'; import { useWeatherAlertsStore } from '../weather-alerts/store'; @@ -420,6 +421,12 @@ export const useSignalRStore = create((set, get) => ({ }, connectChatHub: async () => { try { + // Guard here so every call path (init, app-resume reconnect) honors the flag. + if (!featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) { + logger.info({ message: 'Chat disabled by feature flag; skipping chat hub connection' }); + return; + } + if (get().isChatHubConnected) { return; }