From 00dcaa37f81a9325cc81e32da61a5fa6c896a682 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 7 Aug 2026 17:16:38 -0700 Subject: [PATCH 1/2] RG-T117 chat feature flag --- src/api/feature-flags/feature-flags.ts | 37 ++++++++++++ src/app/(app)/_layout.tsx | 25 +++++--- src/app/(app)/chat.tsx | 12 +++- src/app/(app)/chatbot.tsx | 12 +++- src/app/chat/[channelId].tsx | 13 ++++- src/app/chat/thread/[messageId].tsx | 13 ++++- src/components/sidebar/sidebar-content.tsx | 26 +++++---- src/stores/feature-flags/store.ts | 67 ++++++++++++++++++++++ 8 files changed, 176 insertions(+), 29 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 055a91b..4216873 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -32,6 +32,7 @@ import { bluetoothAudioService } from '@/services/bluetooth-audio.service'; import { usePushNotifications } from '@/services/push-notification'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/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'; @@ -164,19 +165,27 @@ export default function TabLayout() { // These fetches are independent of each other — run in parallel to cut // time-to-interactive (previously 8+ serial network hops). - await Promise.all([useRolesStore.getState().init(), useCallsStore.getState().init(), useWeatherAlertsStore.getState().init(), securityStore.getState().getRights()]); + await Promise.all([useRolesStore.getState().init(), useCallsStore.getState().init(), useWeatherAlertsStore.getState().init(), securityStore.getState().getRights(), featureFlagsStore.getState().fetchFlags()]); // SignalR needs config (EventingUrl) and rights (DepartmentId) — both // available now. The two hub connects are independent. await Promise.all([useSignalRStore.getState().connectUpdateHub(), useSignalRStore.getState().connectGeolocationHub()]); - // Connect the realtime chat hub (best-effort; chat may be disabled per department) - try { - await useSignalRStore.getState().connectChatHub(); - } catch (error) { - logger.warn({ - message: 'Failed to connect SignalR chat hub during initialization', - context: { error, platform: Platform.OS }, + // Connect the realtime chat hub only when the Chat.System feature flag is on for + // this department; when it is off every chat surface stays hidden. + if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) { + // Best-effort: chat hub failure should not block app initialization. + try { + await useSignalRStore.getState().connectChatHub(); + } catch (error) { + logger.warn({ + message: 'Failed to connect SignalR chat hub during initialization', + context: { error, platform: Platform.OS }, + }); + } + } else { + logger.info({ + message: 'Chat disabled by feature flag; skipping chat hub connection', }); } diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx index 195d617..e20e058 100644 --- a/src/app/(app)/chat.tsx +++ b/src/app/(app)/chat.tsx @@ -1,4 +1,4 @@ -import { type Href, useFocusEffect, useRouter } from 'expo-router'; +import { type Href, Redirect, 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(); @@ -81,6 +82,7 @@ function Section({ title, channels, onOpen }: { title: string; channels: ChatCha export default function ChatScreen() { const { t } = useTranslation(); const router = useRouter(); + const isChatEnabled = useIsChatEnabled(); const channels = useChatStore((s) => s.channels); const isLoading = useChatStore((s) => s.isLoadingChannels); const pendingAcks = useChatStore((s) => s.pendingAcks); @@ -89,9 +91,10 @@ export default function ChatScreen() { 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 4620785..5d95bb2 100644 --- a/src/app/(app)/chatbot.tsx +++ b/src/app/(app)/chatbot.tsx @@ -1,4 +1,4 @@ -import { useFocusEffect } from 'expo-router'; +import { Redirect, useFocusEffect } from 'expo-router'; import { RefreshCw, Send, Sparkles } from 'lucide-react-native'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -19,10 +19,12 @@ 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(); const currentUserId = useAuthStore((s) => s.userId); + const isChatEnabled = useIsChatEnabled(); const chatbotChannelId = useChatStore((s) => s.chatbotChannelId); const chatbotTyping = useChatStore((s) => s.chatbotTyping); const messages = useChatStore((s) => (chatbotChannelId ? s.messagesByChannel[chatbotChannelId] : undefined)); @@ -30,12 +32,13 @@ export default function ChatbotScreen() { 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. @@ -61,6 +64,11 @@ export default function ChatbotScreen() { [currentUserId] ); + // Chat.System feature flag off: the assistant rides on the chat system, hide it too. + if (!isChatEnabled) { + return ; + } + return ( diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index cce490e..7024a6a 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 } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -28,6 +28,7 @@ import { ChatChannelType, ChatMessagePriority, type ChatMessageResultData, ChatM import { useCoreStore } from '@/stores/app/core-store'; 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'; @@ -38,6 +39,7 @@ export default function ChannelConversationScreen() { const channelId = Array.isArray(params.channelId) ? params.channelId[0] : params.channelId; const currentUserId = useAuthStore((s) => s.userId); + const isChatEnabled = useIsChatEnabled(); const isModerator = !!securityStore((s) => s.rights)?.IsAdmin; // Unit-app chat identity: messages post as the active unit ("Engine 6"). const activeUnit = useCoreStore((s) => s.activeUnit); @@ -67,7 +69,7 @@ export default function ChannelConversationScreen() { // Mount: activate channel, join hub, load history and members. useFocusEffect( useCallback(() => { - if (!channelId) return; + if (!channelId || !isChatEnabled) return; const store = useChatStore.getState(); store.setActiveChannel(channelId); void store.joinChannel(channelId); @@ -76,7 +78,7 @@ export default function ChannelConversationScreen() { return () => { useChatStore.getState().setActiveChannel(null); }; - }, [channelId]) + }, [channelId, isChatEnabled]) ); // Fetch presence for the channel members (for the header online dot). @@ -239,6 +241,11 @@ export default function ChannelConversationScreen() { const title = channel ? getChannelDisplayName(channel, t) : t('chat.title'); + // Chat.System feature flag off: block deep links into conversations. + if (!isChatEnabled) { + return ; + } + return ( s.userId); + const isChatEnabled = useIsChatEnabled(); const channelMessages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined)); const [fetchedReplies, setFetchedReplies] = useState([]); const root = useMemo(() => (channelMessages ?? []).find((m) => m.ChatMessageId === messageId), [channelMessages, messageId]); useEffect(() => { - if (!messageId) return; + if (!messageId || !isChatEnabled) 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(() => { @@ -94,6 +96,11 @@ export default function ThreadScreen() { [currentUserId, channelId] ); + // Chat.System feature flag off: block deep links into threads. + if (!isChatEnabled) { + return ; + } + return ( diff --git a/src/components/sidebar/sidebar-content.tsx b/src/components/sidebar/sidebar-content.tsx index 540b3b8..287af30 100644 --- a/src/components/sidebar/sidebar-content.tsx +++ b/src/components/sidebar/sidebar-content.tsx @@ -9,6 +9,7 @@ import { HStack } from '@/components/ui/hstack'; import { VStack } from '@/components/ui/vstack'; import { invertColor } from '@/lib/utils'; import { useCoreStore } from '@/stores/app/core-store'; +import { useIsChatEnabled } from '@/stores/feature-flags/store'; import { useStatusBottomSheetStore } from '@/stores/status/store'; import ZeroState from '../common/zero-state'; @@ -25,6 +26,7 @@ interface SidebarProps { const Sidebar = ({ onClose }: SidebarProps) => { const activeStatuses = useCoreStore((state) => state.activeStatuses); const setIsOpen = useStatusBottomSheetStore((state) => state.setIsOpen); + const isChatEnabled = useIsChatEnabled(); const { t } = useTranslation(); const router = useRouter(); @@ -63,17 +65,19 @@ const Sidebar = ({ onClose }: SidebarProps) => { {/* Check-in timer widget */} - {/* Chat + Assistant navigation */} - - - - + {/* Chat + Assistant navigation (hidden when the Chat.System feature flag is off) */} + {isChatEnabled && ( + + + + + )} {/* Third row - Status buttons or empty state */} {isActiveStatusesEmpty ? ( 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 d5340c887b6d905947a21c41943cb75cc9454137 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 8 Aug 2026 08:46:27 -0700 Subject: [PATCH 2/2] RG-T117 PR#262 fixes --- src/app/(app)/chat.tsx | 18 +- src/app/(app)/chatbot.tsx | 18 +- src/app/chat/[channelId].tsx | 17 +- src/app/chat/thread/[messageId].tsx | 18 +- .../__tests__/app-reset.service.test.ts | 36 +++ src/services/app-reset.service.ts | 14 ++ .../feature-flags/__tests__/store.test.ts | 225 ++++++++++++++++++ src/stores/feature-flags/store.ts | 62 ++++- .../signalr/__tests__/signalr-store.test.ts | 10 + src/stores/signalr/__tests__/zz-dbg.test.ts | 4 + src/stores/signalr/signalr-store.ts | 7 + 11 files changed, 414 insertions(+), 15 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 e20e058..643a4b3 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(); @@ -82,7 +83,8 @@ function Section({ title, channels, onOpen }: { title: string; channels: ChatCha export default function ChatScreen() { const { t } = useTranslation(); const router = useRouter(); - const isChatEnabled = useIsChatEnabled(); + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; const channels = useChatStore((s) => s.channels); const isLoading = useChatStore((s) => s.isLoadingChannels); const pendingAcks = useChatStore((s) => s.pendingAcks); @@ -106,8 +108,18 @@ 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 5d95bb2..9c99635 100644 --- a/src/app/(app)/chatbot.tsx +++ b/src/app/(app)/chatbot.tsx @@ -14,17 +14,19 @@ 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(); const currentUserId = useAuthStore((s) => s.userId); - const isChatEnabled = useIsChatEnabled(); + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; const chatbotChannelId = useChatStore((s) => s.chatbotChannelId); const chatbotTyping = useChatStore((s) => s.chatbotTyping); const messages = useChatStore((s) => (chatbotChannelId ? s.messagesByChannel[chatbotChannelId] : undefined)); @@ -64,8 +66,18 @@ export default function ChatbotScreen() { [currentUserId] ); + // 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 (!isChatEnabled) { + if (chatStatus === 'disabled') { return ; } diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index 7024a6a..203ff03 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -28,7 +28,7 @@ import { ChatChannelType, ChatMessagePriority, type ChatMessageResultData, ChatM import { useCoreStore } from '@/stores/app/core-store'; 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'; @@ -39,7 +39,8 @@ export default function ChannelConversationScreen() { const channelId = Array.isArray(params.channelId) ? params.channelId[0] : params.channelId; const currentUserId = useAuthStore((s) => s.userId); - const isChatEnabled = useIsChatEnabled(); + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; const isModerator = !!securityStore((s) => s.rights)?.IsAdmin; // Unit-app chat identity: messages post as the active unit ("Engine 6"). const activeUnit = useCoreStore((s) => s.activeUnit); @@ -241,8 +242,18 @@ export default function ChannelConversationScreen() { const title = channel ? getChannelDisplayName(channel, t) : t('chat.title'); + // 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 conversations. - if (!isChatEnabled) { + if (chatStatus === 'disabled') { return ; } diff --git a/src/app/chat/thread/[messageId].tsx b/src/app/chat/thread/[messageId].tsx index 5dbb618..318c78d 100644 --- a/src/app/chat/thread/[messageId].tsx +++ b/src/app/chat/thread/[messageId].tsx @@ -10,13 +10,14 @@ import { Box } from '@/components/ui/box'; import { Divider } from '@/components/ui/divider'; import { FlatList } from '@/components/ui/flat-list'; 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 { 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(); @@ -25,7 +26,8 @@ export default function ThreadScreen() { const channelId = Array.isArray(params.channelId) ? params.channelId[0] : params.channelId; const currentUserId = useAuthStore((s) => s.userId); - const isChatEnabled = useIsChatEnabled(); + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; const channelMessages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined)); const [fetchedReplies, setFetchedReplies] = useState([]); @@ -96,8 +98,18 @@ export default function ThreadScreen() { [currentUserId, channelId] ); + // 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 (!isChatEnabled) { + 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 12f5967..33591e4 100644 --- a/src/services/__tests__/app-reset.service.test.ts +++ b/src/services/__tests__/app-reset.service.test.ts @@ -168,6 +168,13 @@ jest.mock('@/stores/security/store', () => ({ }, })); +jest.mock('@/stores/feature-flags/store', () => ({ + featureFlagsStore: { + setState: jest.fn(), + getState: jest.fn(() => ({})), + }, +})); + jest.mock('@/stores/status/store', () => ({ useStatusBottomSheetStore: { setState: jest.fn(), @@ -217,6 +224,15 @@ jest.mock('@/stores/check-in-timers/store', () => ({ }, })); +// Mocked so the real chat store's module chain (chat api -> auth store -> +// lib/auth/api axios setup) is never loaded in this suite. +jest.mock('@/stores/chat/store', () => ({ + useChatStore: { + setState: jest.fn(), + getState: jest.fn(), + }, +})); + jest.mock('@/stores/signalr/signalr-store', () => ({ useSignalRStore: { setState: jest.fn(), @@ -234,6 +250,7 @@ import { INITIAL_CONTACTS_STATE, INITIAL_CORE_STATE, INITIAL_DISPATCH_STATE, + INITIAL_FEATURE_FLAGS_STATE, INITIAL_LIVEKIT_STATE, INITIAL_LOCATION_STATE, INITIAL_MAPS_STATE, @@ -261,6 +278,7 @@ const mockAudioCleanup = jest.fn().mockResolvedValue(undefined); const mockLiveKitDisconnect = jest.fn().mockResolvedValue(undefined); const mockWeatherAlertsReset = jest.fn(); const mockCheckInTimerReset = jest.fn(); +const mockChatReset = jest.fn(); describe('app-reset.service', () => { beforeEach(() => { @@ -274,6 +292,7 @@ describe('app-reset.service', () => { const { useStatusBottomSheetStore } = jest.requireMock('@/stores/status/store'); const { useWeatherAlertsStore } = jest.requireMock('@/stores/weather-alerts/store'); const { useCheckInTimerStore } = jest.requireMock('@/stores/check-in-timers/store'); + const { useChatStore } = jest.requireMock('@/stores/chat/store'); const { locationService } = jest.requireMock('@/services/location'); const { signalRService } = jest.requireMock('@/services/signalr.service'); @@ -306,6 +325,10 @@ describe('app-reset.service', () => { reset: mockCheckInTimerReset, }); + useChatStore.getState.mockReturnValue({ + reset: mockChatReset, + }); + locationService.stopLocationUpdates.mockResolvedValue(undefined); signalRService.disconnectAll.mockResolvedValue(undefined); }); @@ -429,6 +452,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, @@ -565,12 +597,15 @@ describe('app-reset.service', () => { const { useMapsStore } = jest.requireMock('@/stores/maps/store'); const { usePoisStore } = jest.requireMock('@/stores/pois/store'); const { useRoutesStore } = jest.requireMock('@/stores/routes/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 flags, resolution and identity so the next session fails closed. + expect(featureFlagsStore.setState).toHaveBeenCalledWith(INITIAL_FEATURE_FLAGS_STATE); expect(mockStatusReset).toHaveBeenCalled(); expect(mockOfflineQueueClear).toHaveBeenCalled(); expect(mockLoadingReset).toHaveBeenCalled(); @@ -580,6 +615,7 @@ describe('app-reset.service', () => { expect(useRoutesStore.setState).toHaveBeenCalledWith(INITIAL_ROUTES_STATE); expect(mockWeatherAlertsReset).toHaveBeenCalled(); expect(mockCheckInTimerReset).toHaveBeenCalled(); + expect(mockChatReset).toHaveBeenCalled(); }); it('should disconnect from LiveKit room if connected', async () => { diff --git a/src/services/app-reset.service.ts b/src/services/app-reset.service.ts index 7289aaf..2688883 100644 --- a/src/services/app-reset.service.ts +++ b/src/services/app-reset.service.ts @@ -25,6 +25,7 @@ import { useChatStore } from '@/stores/chat/store'; import { useCheckInTimerStore } from '@/stores/check-in-timers/store'; import { useContactsStore } from '@/stores/contacts/store'; import { useDispatchStore } from '@/stores/dispatch/store'; +import { featureFlagsStore } from '@/stores/feature-flags/store'; import { useMapsStore } from '@/stores/maps/store'; import { useNotesStore } from '@/stores/notes/store'; import { useOfflineQueueStore } from '@/stores/offline-queue/store'; @@ -144,6 +145,13 @@ export const INITIAL_SECURITY_STATE = { rights: null, }; +export const INITIAL_FEATURE_FLAGS_STATE = { + flags: {}, + isLoaded: false, + error: null, + identityKey: null, +}; + export const INITIAL_LOCATION_STATE = { latitude: null, longitude: null, @@ -267,6 +275,11 @@ export const resetAllStores = async (): Promise => { useDispatchStore.setState(INITIAL_DISPATCH_STATE); securityStore.setState(INITIAL_SECURITY_STATE); + // Feature flags — clearPersistedStorage() wipes 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 previous identity's flags. + featureFlagsStore.setState(INITIAL_FEATURE_FLAGS_STATE); + // Stores with existing reset/clear methods useStatusBottomSheetStore.getState().reset(); useOfflineQueueStore.getState().clearAllEvents(); @@ -431,6 +444,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/__tests__/signalr-store.test.ts b/src/stores/signalr/__tests__/signalr-store.test.ts index ac9f5f4..6927a4f 100644 --- a/src/stores/signalr/__tests__/signalr-store.test.ts +++ b/src/stores/signalr/__tests__/signalr-store.test.ts @@ -48,6 +48,16 @@ jest.mock('../../app/core-store', () => { }; }); +// Feature flags: default the chat flag to enabled so connectChatHub is not short-circuited. +jest.mock('../../feature-flags/store', () => ({ + FeatureFlagKeys: { ChatSystem: 'Chat.System' }, + featureFlagsStore: { + getState: jest.fn(() => ({ + isEnabled: jest.fn(() => true), + })), + }, +})); + jest.mock('../../security/store', () => ({ securityStore: { getState: jest.fn(() => ({ diff --git a/src/stores/signalr/__tests__/zz-dbg.test.ts b/src/stores/signalr/__tests__/zz-dbg.test.ts index d57ebc6..dbd3130 100644 --- a/src/stores/signalr/__tests__/zz-dbg.test.ts +++ b/src/stores/signalr/__tests__/zz-dbg.test.ts @@ -31,6 +31,10 @@ jest.mock('@/stores/security/store', () => { console.log('ALIAS security mock factory ran'); return { securityStore: mockSecurityStore }; }); +jest.mock('../../feature-flags/store', () => ({ + FeatureFlagKeys: { ChatSystem: 'Chat.System' }, + featureFlagsStore: { getState: jest.fn(() => ({ isEnabled: jest.fn(() => true) })) }, +})); jest.mock('@/lib/logging', () => ({ logger: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), trace: jest.fn(), fatal: jest.fn() } })); jest.mock('@/lib/env', () => ({ Env: { CHANNEL_HUB_NAME: 'eventingHub', REALTIME_GEO_HUB_NAME: 'geolocationHub' } })); jest.mock('@/lib', () => ({ useAuthStore: { getState: jest.fn(() => ({ accessToken: 'mock-token' })) } })); diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 18af4c1..f4fca1d 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -7,6 +7,7 @@ import { SignalRService, signalRService } from '@/services/signalr.service'; import { useCoreStore } from '../app/core-store'; import { useChatStore } from '../chat/store'; +import { FeatureFlagKeys, featureFlagsStore } from '../feature-flags/store'; import { securityStore } from '../security/store'; import { useWeatherAlertsStore } from '../weather-alerts/store'; @@ -378,6 +379,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; }